update copy links

This commit is contained in:
2026-06-20 17:27:56 +10:00
parent 91b16aba53
commit d49d0e8e04
8 changed files with 499 additions and 22 deletions
+1
View File
@@ -103,6 +103,7 @@ export default function App() {
<LinkDetailPage
linkId={linkDetailId}
linkScope={linkDetailScope}
currentUser={user}
onBack={() => {
if (linkDetailScope === 'public') {
navigate('home');
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react';
interface CopyPublicLinkDialogProps {
readonly conflictingAlias: string;
readonly suggestedAlias: string;
readonly onSubmit: (alias: string) => Promise<void>;
readonly onClose: () => void;
}
export default function CopyPublicLinkDialog({
conflictingAlias,
suggestedAlias,
onSubmit,
onClose,
}: CopyPublicLinkDialogProps) {
const [alias, setAlias] = useState(suggestedAlias);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
setError('Alias is required');
return;
}
if (normalizedAlias.toLowerCase() === conflictingAlias.toLowerCase()) {
setError('Please choose a different alias.');
return;
}
setError(null);
setSubmitting(true);
try {
await onSubmit(normalizedAlias);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create private copy');
} finally {
setSubmitting(false);
}
}
return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Choose a different alias">
<form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header">
<h2>Alias already in use</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
</div>
<p className="muted" style={{ margin: 0 }}>
You already have <span className="alias-pill">/{conflictingAlias}</span> in My Links. Choose a different alias for this copy.
</p>
<div className="field">
<label htmlFor="copy-link-alias">Private alias</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">/</span>
<input
id="copy-link-alias"
name="alias"
value={alias}
onChange={(event) => setAlias(event.target.value)}
required
maxLength={100}
autoComplete="off"
placeholder="my-link-copy"
disabled={submitting}
/>
</div>
<small className="hint alias-preview">
<span>This copy will be created in your private links.</span>
{alias.trim() ? <span className="alias-pill">/{alias.trim()}</span> : null}
</small>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="form-actions">
<button type="submit" disabled={submitting || !alias.trim()}>
{submitting ? 'Creating…' : 'Create link'}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
</div>
</form>
</div>
);
}
+9 -5
View File
@@ -94,9 +94,9 @@ function jsonBody(input: LinkInput): string {
return JSON.stringify({
alias: input.alias,
linkType: input.linkType,
targetUrl: input.targetUrl ?? null,
contentMarkdown: input.contentMarkdown ?? null,
description: input.description ?? null,
...(input.targetUrl !== undefined ? { targetUrl: input.targetUrl ?? null } : {}),
...(input.contentMarkdown !== undefined ? { contentMarkdown: input.contentMarkdown } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
});
}
@@ -134,6 +134,10 @@ export function listPublicLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/public');
}
export function getPublicLink(id: string): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>(`/api/links/public/${encodeURIComponent(id)}`);
}
// ---- Admin public-link management ----
export function createPublicLink(input: LinkInput): Promise<LinkMutationResponse> {
@@ -281,11 +285,11 @@ export function getPrivateLinkHistory(id: string): Promise<LinkHistoryResponse>
}
export function getPublicLinkStats(id: string, period: StatsPeriod): Promise<LinkStatsResponse> {
return request<LinkStatsResponse>(`/api/admin/public-links/${encodeURIComponent(id)}/stats?period=${period}`);
return request<LinkStatsResponse>(`/api/links/public/${encodeURIComponent(id)}/stats?period=${period}`);
}
export function getPublicLinkHistory(id: string): Promise<LinkHistoryResponse> {
return request<LinkHistoryResponse>(`/api/admin/public-links/${encodeURIComponent(id)}/history`);
return request<LinkHistoryResponse>(`/api/links/public/${encodeURIComponent(id)}/history`);
}
function reviewBody(input: ReviewActionInput): Record<string, string> {
+188 -13
View File
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Chart, registerables } from 'chart.js';
import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog';
import {
ApiClientError,
type ChangeLogEntry,
type DayCount,
type Link,
createPrivateLink,
getPublicLink,
type StatsPeriod,
getPrivateLink,
getPrivateLinkHistory,
@@ -11,6 +15,7 @@ import {
getPublicLinkHistory,
getPublicLinkStats,
} from '../lib/api';
import type { CurrentUser } from '../lib/auth';
import { safeLinkTargetUrl } from '../lib/url';
Chart.register(...registerables);
@@ -18,9 +23,15 @@ Chart.register(...registerables);
interface LinkDetailPageProps {
linkId: string;
linkScope: 'private' | 'public';
currentUser: CurrentUser | null;
onBack: () => void;
}
type CopySuccessNotice = {
linkId: string;
alias: string;
};
const CHANGE_TYPE_LABELS: Record<string, string> = {
created: 'Link created',
url_changed: 'URL changed',
@@ -37,7 +48,9 @@ const PERIODS: { id: StatsPeriod; label: string }[] = [
{ id: 'all', label: 'All Time' },
];
export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetailPageProps) {
const COPY_SUCCESS_NOTICE_KEY = 'heygo.copy-success-notice';
export default function LinkDetailPage({ linkId, linkScope, currentUser, onBack }: LinkDetailPageProps) {
const [link, setLink] = useState<Link | null>(null);
const [stats, setStats] = useState<DayCount[]>([]);
const [history, setHistory] = useState<ChangeLogEntry[]>([]);
@@ -46,19 +59,18 @@ export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetail
const [loadingStats, setLoadingStats] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copyError, setCopyError] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const [showCopyDialog, setShowCopyDialog] = useState(false);
const [copySuccessNotice, setCopySuccessNotice] = useState<CopySuccessNotice | null>(null);
const chartRef = useRef<HTMLCanvasElement | null>(null);
const chartInstance = useRef<Chart | null>(null);
useEffect(() => {
setError(null);
if (linkScope !== 'private') {
setLink(null);
setLoadingLink(false);
return;
}
setLoadingLink(true);
void getPrivateLink(linkId)
const fetchLink = linkScope === 'private' ? getPrivateLink : getPublicLink;
void fetchLink(linkId)
.then((data) => {
setLink(data.link);
})
@@ -70,6 +82,39 @@ export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetail
});
}, [linkId, linkScope]);
useEffect(() => {
setCopyError(null);
setShowCopyDialog(false);
setCopying(false);
}, [linkId, linkScope]);
useEffect(() => {
if (linkScope !== 'private') {
setCopySuccessNotice(null);
return;
}
const notice = takeCopySuccessNotice();
if (!notice || notice.linkId !== linkId) {
setCopySuccessNotice(null);
return;
}
setCopySuccessNotice(notice);
}, [linkId, linkScope]);
useEffect(() => {
if (!copySuccessNotice) {
return;
}
const timeoutId = window.setTimeout(() => {
setCopySuccessNotice(null);
}, 4200);
return () => window.clearTimeout(timeoutId);
}, [copySuccessNotice]);
const loadStats = useCallback(async (selectedPeriod: StatsPeriod) => {
setLoadingStats(true);
try {
@@ -190,21 +235,85 @@ export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetail
}
const safeUrl = link?.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
const canCopyToPrivate = linkScope === 'public' && currentUser != null && link != null;
async function createPrivateCopy(alias: string) {
if (!link || link.scope !== 'public') {
throw new Error('Public link details are not loaded yet');
}
if (link.linkType === 'redirect' && !link.targetUrl) {
throw new Error('This public link has no target URL');
}
if (link.linkType === 'custom' && !link.contentMarkdown) {
throw new Error('This public link has no custom content');
}
const result = await createPrivateLink({
alias,
linkType: link.linkType,
targetUrl: link.linkType === 'redirect' ? (link.targetUrl ?? undefined) : undefined,
contentMarkdown: link.linkType === 'custom' ? (link.contentMarkdown ?? undefined) : undefined,
description: link.description ?? undefined,
});
saveCopySuccessNotice({
linkId: result.link.id,
alias: result.link.alias,
});
window.location.hash = `/links/private/${encodeURIComponent(result.link.id)}`;
}
async function handleCopyClick() {
if (!link || link.scope !== 'public' || copying) {
return;
}
setCopyError(null);
setCopying(true);
try {
await createPrivateCopy(link.alias);
} catch (err) {
if (err instanceof ApiClientError && err.status === 409) {
setShowCopyDialog(true);
} else {
setCopyError(err instanceof Error ? err.message : 'Failed to copy link');
}
} finally {
setCopying(false);
}
}
return (
<div style={{ width: '100%', maxWidth: 960, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{copySuccessNotice ? (
<CopySuccessToast
alias={copySuccessNotice.alias}
onClose={() => setCopySuccessNotice(null)}
/>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" className="link-action" onClick={onBack} style={{ flexShrink: 0 }}> Back</button>
{link ? <span className="alias-pill" style={{ fontSize: '1rem' }}>#{link.alias}</span> : null}
{link ? <span className="alias-pill" style={{ fontSize: '1rem' }}>/{link.alias}</span> : null}
</div>
{link ? (
<section className="panel" style={{ padding: '1.5rem' }}>
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Link Details
</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, margin: 0, letterSpacing: '-0.01em' }}>
Link Details
</h1>
{canCopyToPrivate ? (
<button type="button" className="link-action" onClick={() => void handleCopyClick()} disabled={copying}>
{copying ? 'Copying…' : 'Copy to My Links'}
</button>
) : null}
</div>
{copyError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{copyError}</p> : null}
<dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}>
<MetaItem label="Alias"><span className="alias-pill">#{link.alias}</span></MetaItem>
<MetaItem label="Alias"><span className="alias-pill">/{link.alias}</span></MetaItem>
<MetaItem label="Type">{link.linkType === 'custom' ? 'Custom page' : 'Redirect'}</MetaItem>
<MetaItem label="Total clicks">
<strong style={{ color: 'var(--accent)', fontSize: '1.1rem' }}>{link.clickCount.toLocaleString()}</strong>
@@ -290,6 +399,72 @@ export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetail
</ul>
)}
</section>
{showCopyDialog && link ? (
<CopyPublicLinkDialog
conflictingAlias={link.alias}
suggestedAlias={buildSuggestedCopyAlias(link.alias)}
onClose={() => setShowCopyDialog(false)}
onSubmit={async (alias) => {
await createPrivateCopy(alias);
}}
/>
) : null}
</div>
);
}
function buildSuggestedCopyAlias(alias: string): string {
const suffix = '-copy';
if (alias.length + suffix.length <= 100) {
return `${alias}${suffix}`;
}
return `${alias.slice(0, 100 - suffix.length)}${suffix}`;
}
function saveCopySuccessNotice(notice: CopySuccessNotice): void {
try {
window.sessionStorage.setItem(COPY_SUCCESS_NOTICE_KEY, JSON.stringify(notice));
} catch {
// Ignore storage failures; the copy still succeeded.
}
}
function takeCopySuccessNotice(): CopySuccessNotice | null {
try {
const raw = window.sessionStorage.getItem(COPY_SUCCESS_NOTICE_KEY);
if (!raw) {
return null;
}
window.sessionStorage.removeItem(COPY_SUCCESS_NOTICE_KEY);
const parsed = JSON.parse(raw) as Partial<CopySuccessNotice>;
if (typeof parsed.linkId !== 'string' || typeof parsed.alias !== 'string') {
return null;
}
return {
linkId: parsed.linkId,
alias: parsed.alias,
};
} catch {
return null;
}
}
function CopySuccessToast({ alias, onClose }: { alias: string; onClose: () => void }) {
return (
<div className="copy-success-toast" role="status" aria-live="polite">
<div className="copy-success-toast__chip" aria-hidden="true">/</div>
<div className="copy-success-toast__body">
<p className="copy-success-toast__eyebrow">Private copy saved</p>
<p className="copy-success-toast__title">/{alias} is now in My Links</p>
<p className="copy-success-toast__detail">You can edit it here or keep browsing.</p>
</div>
<button type="button" className="copy-success-toast__close" onClick={onClose} aria-label="Dismiss notice">
×
</button>
<span className="copy-success-toast__meter" aria-hidden="true" />
</div>
);
}
+133
View File
@@ -753,6 +753,132 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
outline: none;
}
.copy-success-toast {
position: fixed;
top: 5.5rem;
right: 1.5rem;
z-index: 45;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.875rem;
align-items: start;
width: min(25rem, calc(100vw - 2rem));
padding: 0.95rem 1rem 1.1rem;
border-radius: 20px;
border: 1px solid rgba(129, 140, 248, 0.22);
background:
linear-gradient(135deg, rgba(129, 140, 248, 0.16), rgba(15, 23, 42, 0) 52%),
#0F172A;
color: #F8FAFC;
box-shadow: 0 22px 60px rgba(15,23,42,0.22), 0 8px 20px rgba(15,23,42,0.14);
overflow: hidden;
animation: copy-success-toast-enter 180ms ease-out;
}
.copy-success-toast__chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(99, 102, 241, 0.22);
border: 1px solid rgba(165, 180, 252, 0.35);
color: #C7D2FE;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem;
font-weight: 700;
}
.copy-success-toast__body {
min-width: 0;
}
.copy-success-toast__eyebrow,
.copy-success-toast__title,
.copy-success-toast__detail {
margin: 0;
}
.copy-success-toast__eyebrow {
color: #A5B4FC;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.copy-success-toast__title {
margin-top: 0.2rem;
color: #F8FAFC;
font-family: 'Syne', ui-sans-serif, sans-serif;
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.copy-success-toast__detail {
margin-top: 0.2rem;
color: #CBD5E1;
font-size: 0.8125rem;
}
.copy-success-toast__close {
appearance: none;
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255,255,255,0.04);
color: #CBD5E1;
line-height: 1;
}
.copy-success-toast__close:hover {
background: rgba(255,255,255,0.1);
color: #FFFFFF;
}
.copy-success-toast__meter {
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0.7rem;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, #818CF8 0%, rgba(129, 140, 248, 0.18) 100%);
transform-origin: left center;
animation: copy-success-toast-meter 4200ms linear forwards;
}
@keyframes copy-success-toast-enter {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes copy-success-toast-meter {
from {
transform: scaleX(1);
opacity: 1;
}
to {
transform: scaleX(0);
opacity: 0.65;
}
}
@media (prefers-reduced-motion: reduce) {
.copy-success-toast,
.copy-success-toast__meter {
animation: none;
}
}
/* ═══════════════════════════════════════════
Admin review
════════════════════════════════════════════ */
@@ -1038,4 +1164,11 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
.truncate { max-width: 10rem; }
.user-name { display: none; }
.user-dropdown { right: -0.5rem; }
.copy-success-toast {
top: auto;
right: 1rem;
left: 1rem;
bottom: 1rem;
width: auto;
}
}
+16 -2
View File
@@ -5,6 +5,9 @@ import {
createPrivateLink,
deletePrivateLink,
deletePublicLink,
getPublicLink,
getPublicLinkHistory,
getPublicLinkStats,
listPrivateLinks,
listPublicLinks,
updatePrivateLink,
@@ -91,7 +94,6 @@ describe('api client', () => {
alias: 'Docs',
linkType: 'redirect',
targetUrl: 'https://example.com',
contentMarkdown: null,
description: ' spaced ',
});
expect(result.link.id).toBe('new');
@@ -108,7 +110,7 @@ describe('api client', () => {
expect(calls[0].init?.method).toBe('PATCH');
const body = JSON.parse(calls[0].init?.body as string);
expect(body.linkType).toBe('custom');
expect(body.targetUrl).toBeNull();
expect(body).not.toHaveProperty('targetUrl');
expect(body.contentMarkdown).toBe('# Hi');
});
@@ -158,6 +160,18 @@ describe('api client', () => {
expect(calls[0].path).toBe('/api/links/public');
});
it('public detail endpoints use the readable /api/links/public routes', async () => {
const { calls } = mockFetch(() => ({ body: { link: {}, stats: [], history: [] } }));
await getPublicLink('pub_1');
await getPublicLinkStats('pub_1', '3m');
await getPublicLinkHistory('pub_1');
expect(calls[0].path).toBe('/api/links/public/pub_1');
expect(calls[1].path).toBe('/api/links/public/pub_1/stats?period=3m');
expect(calls[2].path).toBe('/api/links/public/pub_1/history');
});
it('encodes link ids containing special path segments', async () => {
const { calls } = mockFetch(() => ({ body: { ok: true } }));
+29
View File
@@ -588,6 +588,35 @@ describe('link CRUD API', () => {
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['public_active']);
});
it('returns a public link detail without login', async () => {
const { response } = await fetchWorker('/api/links/public/public_active', {
links: [
link({ id: 'public_active', scope: 'public', owner_user_id: null, alias: 'pub', status: 'active' }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.link).toMatchObject({
id: 'public_active',
alias: 'pub',
scope: 'public',
ownerUserId: null,
});
});
it('returns public link stats and history without admin access', async () => {
const links = [link({ id: 'public_active', scope: 'public', owner_user_id: null, alias: 'pub', status: 'active' })];
const stats = await fetchWorker('/api/links/public/public_active/stats?period=3m', { links });
expect(stats.response.status).toBe(200);
await expect(expectJson(stats.response)).resolves.toEqual({ stats: [], period: '3m' });
const history = await fetchWorker('/api/links/public/public_active/history', { links });
expect(history.response.status).toBe(200);
await expect(expectJson(history.response)).resolves.toEqual({ history: [] });
});
it('rejects non-admin public link creation with 403', async () => {
const session = await userSession('token-a', 'user_1', 'user');
const { response } = await fetchWorker('/api/admin/public-links', {
+34 -2
View File
@@ -180,6 +180,32 @@ export async function handleLinksApi(request: Request, env: Env): Promise<Respon
return methodNotAllowed();
}
if (pathname.match(/^\/api\/links\/public\/[^/]+$/) && request.method === 'GET') {
const id = decodePathSegment(pathname.split('/').pop()!);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getPublicLink(env, id);
}
const publicReadableStatsMatch = pathname.match(/^\/api\/links\/public\/([^/]+)\/stats$/);
if (publicReadableStatsMatch && request.method === 'GET') {
const id = decodePathSegment(publicReadableStatsMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkStats(request, env, id, 'public');
}
const publicReadableHistoryMatch = pathname.match(/^\/api\/links\/public\/([^/]+)\/history$/);
if (publicReadableHistoryMatch && request.method === 'GET') {
const id = decodePathSegment(publicReadableHistoryMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkHistory(request, env, id, 'public');
}
if (pathname === '/api/admin/public-links') {
if (request.method === 'POST') {
return await createPublicLink(request, env);
@@ -250,6 +276,14 @@ async function listPublicLinks(env: Env): Promise<Response> {
return json({ links: (result.results ?? []).map(toLinkJson) });
}
async function getPublicLink(env: Env, id: string): Promise<Response> {
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
if (!link) {
return json({ error: 'Link not found' }, { status: 404 });
}
return json({ link: toLinkJson(link) });
}
async function createPrivateLink(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const input = await readAndValidateInput(request);
@@ -564,7 +598,6 @@ async function getLinkStats(request: Request, env: Env, id: string, scope: 'priv
return json({ error: 'Link not found' }, { status: 404 });
}
} else {
await requireAdmin(request, env);
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
if (!link) {
return json({ error: 'Link not found' }, { status: 404 });
@@ -596,7 +629,6 @@ async function getLinkHistory(request: Request, env: Env, id: string, scope: 'pr
return json({ error: 'Link not found' }, { status: 404 });
}
} else {
await requireAdmin(request, env);
const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
if (!link) {
return json({ error: 'Link not found' }, { status: 404 });