mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
update copy links
This commit is contained in:
@@ -103,6 +103,7 @@ export default function App() {
|
||||
<LinkDetailPage
|
||||
linkId={linkDetailId}
|
||||
linkScope={linkDetailScope}
|
||||
currentUser={user}
|
||||
onBack={() => {
|
||||
if (linkDetailScope === 'public') {
|
||||
navigate('home');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }));
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user