diff --git a/src/App.tsx b/src/App.tsx index 70729a8..6ff922c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,6 +103,7 @@ export default function App() { { if (linkDetailScope === 'public') { navigate('home'); diff --git a/src/components/CopyPublicLinkDialog.tsx b/src/components/CopyPublicLinkDialog.tsx new file mode 100644 index 0000000..2609c2a --- /dev/null +++ b/src/components/CopyPublicLinkDialog.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; + +interface CopyPublicLinkDialogProps { + readonly conflictingAlias: string; + readonly suggestedAlias: string; + readonly onSubmit: (alias: string) => Promise; + readonly onClose: () => void; +} + +export default function CopyPublicLinkDialog({ + conflictingAlias, + suggestedAlias, + onSubmit, + onClose, +}: CopyPublicLinkDialogProps) { + const [alias, setAlias] = useState(suggestedAlias); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + 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 ( +
+
+
+

Alias already in use

+ +
+ +

+ You already have /{conflictingAlias} in My Links. Choose a different alias for this copy. +

+ +
+ +
+ + setAlias(event.target.value)} + required + maxLength={100} + autoComplete="off" + placeholder="my-link-copy" + disabled={submitting} + /> +
+ + This copy will be created in your private links. + {alias.trim() ? /{alias.trim()} : null} + +
+ + {error ?

{error}

: null} + +
+ + +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index ced5cff..46c7818 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { return request('/api/links/public'); } +export function getPublicLink(id: string): Promise { + return request(`/api/links/public/${encodeURIComponent(id)}`); +} + // ---- Admin public-link management ---- export function createPublicLink(input: LinkInput): Promise { @@ -281,11 +285,11 @@ export function getPrivateLinkHistory(id: string): Promise } export function getPublicLinkStats(id: string, period: StatsPeriod): Promise { - return request(`/api/admin/public-links/${encodeURIComponent(id)}/stats?period=${period}`); + return request(`/api/links/public/${encodeURIComponent(id)}/stats?period=${period}`); } export function getPublicLinkHistory(id: string): Promise { - return request(`/api/admin/public-links/${encodeURIComponent(id)}/history`); + return request(`/api/links/public/${encodeURIComponent(id)}/history`); } function reviewBody(input: ReviewActionInput): Record { diff --git a/src/routes/LinkDetailPage.tsx b/src/routes/LinkDetailPage.tsx index 9637825..475928b 100644 --- a/src/routes/LinkDetailPage.tsx +++ b/src/routes/LinkDetailPage.tsx @@ -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 = { 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(null); const [stats, setStats] = useState([]); const [history, setHistory] = useState([]); @@ -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(null); + const [copyError, setCopyError] = useState(null); + const [copying, setCopying] = useState(false); + const [showCopyDialog, setShowCopyDialog] = useState(false); + const [copySuccessNotice, setCopySuccessNotice] = useState(null); const chartRef = useRef(null); const chartInstance = useRef(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 (
+ {copySuccessNotice ? ( + setCopySuccessNotice(null)} + /> + ) : null} +
- {link ? #{link.alias} : null} + {link ? /{link.alias} : null}
{link ? (
-

- Link Details -

+
+

+ Link Details +

+ {canCopyToPrivate ? ( + + ) : null} +
+ {copyError ?

{copyError}

: null}
- #{link.alias} + /{link.alias} {link.linkType === 'custom' ? 'Custom page' : 'Redirect'} {link.clickCount.toLocaleString()} @@ -290,6 +399,72 @@ export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetail )}
+ + {showCopyDialog && link ? ( + setShowCopyDialog(false)} + onSubmit={async (alias) => { + await createPrivateCopy(alias); + }} + /> + ) : null} +
+ ); +} + +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; + 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 ( +
+ +
+

Private copy saved

+

/{alias} is now in My Links

+

You can edit it here or keep browsing.

+
+ +
); } diff --git a/src/styles.css b/src/styles.css index be7b580..e9c4fb6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; + } } diff --git a/tests/api.client.test.ts b/tests/api.client.test.ts index be9aed6..9b54490 100644 --- a/tests/api.client.test.ts +++ b/tests/api.client.test.ts @@ -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 } })); diff --git a/tests/api.links.test.ts b/tests/api.links.test.ts index 5a7c31d..99e20d6 100644 --- a/tests/api.links.test.ts +++ b/tests/api.links.test.ts @@ -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', { diff --git a/worker/routes/api.links.ts b/worker/routes/api.links.ts index b9e861c..4a2a41b 100644 --- a/worker/routes/api.links.ts +++ b/worker/routes/api.links.ts @@ -180,6 +180,32 @@ export async function handleLinksApi(request: Request, env: Env): Promise { return json({ links: (result.results ?? []).map(toLinkJson) }); } +async function getPublicLink(env: Env, id: string): Promise { + const link = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first(); + if (!link) { + return json({ error: 'Link not found' }, { status: 404 }); + } + return json({ link: toLinkJson(link) }); +} + async function createPrivateLink(request: Request, env: Env): Promise { 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(); 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(); if (!link) { return json({ error: 'Link not found' }, { status: 404 });