Update UI

This commit is contained in:
2026-06-20 16:10:20 +10:00
parent 792a33fecd
commit cb73e17261
12 changed files with 785 additions and 30 deletions
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE link_change_logs (
id TEXT PRIMARY KEY,
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
change_type TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX link_change_logs_link_idx ON link_change_logs(link_id, changed_at DESC);
+41 -11
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.1",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
@@ -146,8 +147,7 @@
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260619.1.tgz",
"integrity": "sha512-bprsNzG0DapQPFwU2AvQlQ6FUO7Y4bKWaPBzLNI7nBSqlHsK0P62xx2NaNT6i/htzAgQspKZm1D32y0RxofrRQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"peer": true
"license": "MIT OR Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
@@ -162,6 +162,27 @@
"node": ">=12"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1208,6 +1229,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -3361,7 +3388,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3372,7 +3398,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3555,6 +3580,18 @@
"node": ">=18"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3656,7 +3693,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -4122,7 +4158,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4240,7 +4275,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4250,7 +4284,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4575,7 +4608,6 @@
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"pathe": "^2.0.3"
}
@@ -4628,7 +4660,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4815,7 +4846,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"workerd": "bin/workerd"
},
+1
View File
@@ -22,6 +22,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.1",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
+38 -2
View File
@@ -4,10 +4,11 @@ import PrivateLinksPage from './routes/PrivateLinksPage';
import PublicLinksPage from './routes/PublicLinksPage';
import AdminReviewPage from './routes/AdminReviewPage';
import DevLoginPage from './routes/DevLoginPage';
import LinkDetailPage from './routes/LinkDetailPage';
import UserMenu from './components/UserMenu';
import { useCurrentUser } from './lib/auth';
type RouteId = 'home' | 'my-links' | 'admin' | 'login' | 'dev-login' | 'profile' | 'settings';
type RouteId = 'home' | 'my-links' | 'admin' | 'login' | 'dev-login' | 'profile' | 'settings' | 'link-detail';
const NAV_TABS: readonly { id: RouteId; label: string }[] = [
{ id: 'my-links', label: 'My Links' },
@@ -16,6 +17,7 @@ const NAV_TABS: readonly { id: RouteId; label: string }[] = [
function readRouteFromHash(): RouteId {
const hash = window.location.hash;
if (!hash || hash === '#/' || hash === '#') return 'home';
if (hash.match(/^#\/links\//)) return 'link-detail';
const match = hash.match(/^#\/(my-links|admin|login|dev-login|profile|settings)/);
return (match?.[1] as RouteId) ?? 'home';
}
@@ -24,12 +26,33 @@ function navigate(route: RouteId) {
window.location.hash = route === 'home' ? '/' : `/${route}`;
}
export function navigateToLinkDetail(id: string, scope: 'private' | 'public') {
window.location.hash = `/links/${scope}/${encodeURIComponent(id)}`;
}
export default function App() {
const [route, setRoute] = useState<RouteId>(() => readRouteFromHash());
const [linkDetailId, setLinkDetailId] = useState<string | null>(null);
const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private');
const { user, loading, refresh } = useCurrentUser();
useEffect(() => {
const onHashChange = () => setRoute(readRouteFromHash());
const syncRouteFromHash = () => {
const newRoute = readRouteFromHash();
setRoute(newRoute);
if (newRoute === 'link-detail') {
const match = window.location.hash.match(/^#\/links\/(private|public)\/([^?#]+)/);
if (match) {
setLinkDetailScope(match[1] as 'private' | 'public');
setLinkDetailId(decodeURIComponent(match[2]));
return;
}
}
setLinkDetailId(null);
};
syncRouteFromHash();
const onHashChange = () => syncRouteFromHash();
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
@@ -76,6 +99,19 @@ export default function App() {
{route === 'dev-login' ? <DevLoginPage /> : null}
{route === 'profile' ? <PlaceholderPage title="Profile" /> : null}
{route === 'settings' ? <PlaceholderPage title="Settings" /> : null}
{route === 'link-detail' && linkDetailId ? (
<LinkDetailPage
linkId={linkDetailId}
linkScope={linkDetailScope}
onBack={() => {
if (linkDetailScope === 'public') {
navigate('home');
} else {
navigate('my-links');
}
}}
/>
) : null}
</main>
<footer className="app-footer">
+20 -7
View File
@@ -64,7 +64,7 @@ export default function LinkTable({
<th scope="col">Target / content</th>
<th scope="col">Clicks</th>
<th scope="col">Updated</th>
{actions ? <th scope="col" className="col-actions">Actions</th> : null}
<th scope="col" className="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@@ -84,7 +84,15 @@ export default function LinkTable({
</td>
) : null}
<td className="col-alias">
<span className="alias-pill">#{link.alias}</span>
<a
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none' }}
>
#{link.alias}
</a>
{link.description ? <small className="row-description">{link.description}</small> : null}
</td>
<td>{link.linkType === 'custom' ? 'Custom' : 'Redirect'}</td>
@@ -109,18 +117,23 @@ export default function LinkTable({
<td className="col-updated">
<time dateTime={link.updatedAt}>{new Date(link.updatedAt).toLocaleString()}</time>
</td>
{actions ? (
<td className="col-actions">
{onEdit ? (
<td className="col-actions">
{actions && onEdit ? (
<button type="button" className="link-action" onClick={() => onEdit(link)}>Edit</button>
) : null}
{onDelete ? (
<button
type="button"
className="link-action"
onClick={() => { window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`; }}
>
View
</button>
{actions && onDelete ? (
<button type="button" className="link-action link-action--danger" onClick={() => onDelete(link)}>
Delete
</button>
) : null}
</td>
) : null}
</tr>
);
})}
+46
View File
@@ -106,6 +106,10 @@ export function listPrivateLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/private');
}
export function getPrivateLink(id: string): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>(`/api/links/private/${encodeURIComponent(id)}`);
}
export function createPrivateLink(input: LinkInput): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>('/api/links/private', {
method: 'POST',
@@ -242,6 +246,48 @@ export function needsChangesSubmission(id: string, input: ReviewActionInput = {}
});
}
// ---- Link detail (stats + history) ----
export interface DayCount {
day: string;
count: number;
}
export interface LinkStatsResponse {
stats: DayCount[];
period: string;
}
export interface ChangeLogEntry {
id: string;
changeType: string;
oldValue: string | null;
newValue: string | null;
changedAt: string;
}
export interface LinkHistoryResponse {
history: ChangeLogEntry[];
}
export type StatsPeriod = '3m' | '6m' | '1y' | 'all';
export function getPrivateLinkStats(id: string, period: StatsPeriod): Promise<LinkStatsResponse> {
return request<LinkStatsResponse>(`/api/links/private/${encodeURIComponent(id)}/stats?period=${period}`);
}
export function getPrivateLinkHistory(id: string): Promise<LinkHistoryResponse> {
return request<LinkHistoryResponse>(`/api/links/private/${encodeURIComponent(id)}/history`);
}
export function getPublicLinkStats(id: string, period: StatsPeriod): Promise<LinkStatsResponse> {
return request<LinkStatsResponse>(`/api/admin/public-links/${encodeURIComponent(id)}/stats?period=${period}`);
}
export function getPublicLinkHistory(id: string): Promise<LinkHistoryResponse> {
return request<LinkHistoryResponse>(`/api/admin/public-links/${encodeURIComponent(id)}/history`);
}
function reviewBody(input: ReviewActionInput): Record<string, string> {
return input.reason !== undefined && input.reason !== '' ? { reason: input.reason } : {};
}
+377
View File
@@ -0,0 +1,377 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Chart, registerables } from 'chart.js';
import {
type ChangeLogEntry,
type DayCount,
type Link,
type StatsPeriod,
getPrivateLink,
getPrivateLinkHistory,
getPrivateLinkStats,
getPublicLinkHistory,
getPublicLinkStats,
} from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url';
Chart.register(...registerables);
interface LinkDetailPageProps {
linkId: string;
linkScope: 'private' | 'public';
onBack: () => void;
}
const CHANGE_TYPE_LABELS: Record<string, string> = {
created: 'Link created',
url_changed: 'URL changed',
alias_changed: 'Alias changed',
description_changed: 'Description changed',
content_changed: 'Content changed',
type_changed: 'Type changed',
};
const PERIODS: { id: StatsPeriod; label: string }[] = [
{ id: '3m', label: '3 Months' },
{ id: '6m', label: '6 Months' },
{ id: '1y', label: '1 Year' },
{ id: 'all', label: 'All Time' },
];
export default function LinkDetailPage({ linkId, linkScope, onBack }: LinkDetailPageProps) {
const [link, setLink] = useState<Link | null>(null);
const [stats, setStats] = useState<DayCount[]>([]);
const [history, setHistory] = useState<ChangeLogEntry[]>([]);
const [period, setPeriod] = useState<StatsPeriod>('3m');
const [loadingLink, setLoadingLink] = useState(true);
const [loadingStats, setLoadingStats] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(true);
const [error, setError] = useState<string | 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)
.then((data) => {
setLink(data.link);
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load link details');
})
.finally(() => {
setLoadingLink(false);
});
}, [linkId, linkScope]);
const loadStats = useCallback(async (selectedPeriod: StatsPeriod) => {
setLoadingStats(true);
try {
const fetchStats = linkScope === 'private' ? getPrivateLinkStats : getPublicLinkStats;
const result = await fetchStats(linkId, selectedPeriod);
setStats(result.stats);
} catch {
setStats([]);
} finally {
setLoadingStats(false);
}
}, [linkId, linkScope]);
useEffect(() => {
void loadStats(period);
}, [loadStats, period]);
useEffect(() => {
setLoadingHistory(true);
const fetchHistory = linkScope === 'private' ? getPrivateLinkHistory : getPublicLinkHistory;
void fetchHistory(linkId)
.then((result) => {
setHistory(result.history);
})
.catch(() => {
setHistory([]);
})
.finally(() => {
setLoadingHistory(false);
});
}, [linkId, linkScope]);
useEffect(() => {
if (!chartRef.current) {
return;
}
if (chartInstance.current) {
chartInstance.current.destroy();
chartInstance.current = null;
}
if (stats.length === 0) {
return;
}
chartInstance.current = new Chart(chartRef.current, {
type: 'line',
data: {
labels: stats.map((stat) => {
const date = new Date(`${stat.day}T00:00:00`);
return date.toLocaleDateString('en-AU', { month: 'short', day: 'numeric' });
}),
datasets: [{
label: 'Clicks',
data: stats.map((stat) => stat.count),
backgroundColor: 'rgba(99,102,241,0.08)',
borderColor: 'rgba(99,102,241,1)',
borderWidth: 2.5,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgba(99,102,241,1)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.04)' },
ticks: {
stepSize: 1,
color: '#94A3B8',
font: { size: 11 },
},
},
x: {
grid: { display: false },
ticks: {
color: '#94A3B8',
font: { size: 11 },
maxTicksLimit: 12,
},
},
},
},
});
return () => {
chartInstance.current?.destroy();
chartInstance.current = null;
};
}, [stats]);
if (loadingLink) {
return (
<section className="panel" style={{ maxWidth: 960 }}>
<p className="table-status" aria-busy="true">Loading</p>
</section>
);
}
if (error) {
return (
<section className="panel" style={{ maxWidth: 960 }}>
<p className="form-error" role="alert">{error}</p>
<button type="button" onClick={onBack} style={{ marginTop: '1rem' }}> Back</button>
</section>
);
}
const safeUrl = link?.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
return (
<div style={{ width: '100%', maxWidth: 960, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<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}
</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>
<dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}>
<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>
</MetaItem>
<MetaItem label="Scope">{link.scope}</MetaItem>
<MetaItem label="Created">{new Date(link.createdAt).toLocaleString()}</MetaItem>
<MetaItem label="Updated">{new Date(link.updatedAt).toLocaleString()}</MetaItem>
{link.description ? <MetaItem label="Description" wide>{link.description}</MetaItem> : null}
{link.linkType === 'redirect' && link.targetUrl ? (
<MetaItem label="Target URL" wide>
{safeUrl ? (
<a
href={safeUrl}
target="_blank"
rel="noreferrer noopener"
className="truncate"
style={{ maxWidth: '100%', display: 'inline-block', verticalAlign: 'bottom' }}
>
{link.targetUrl}
</a>
) : (
<span className="muted">{link.targetUrl}</span>
)}
</MetaItem>
) : null}
</dl>
</section>
) : null}
<section className="panel" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: 0, letterSpacing: '-0.01em' }}>
Click Statistics
</h2>
<div style={{ display: 'flex', gap: '0.35rem' }}>
{PERIODS.map((entry) => (
<button
key={entry.id}
type="button"
className={`tab-button${period === entry.id ? ' is-active' : ''}`}
onClick={() => setPeriod(entry.id)}
disabled={loadingStats}
>
{entry.label}
</button>
))}
</div>
</div>
<div style={{ height: 280, position: 'relative' }}>
{loadingStats ? (
<p
className="table-status"
aria-busy="true"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
>
Loading
</p>
) : stats.length === 0 ? (
<p
className="table-empty"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
>
No click data for this period.
</p>
) : null}
<canvas ref={chartRef} style={{ display: stats.length === 0 || loadingStats ? 'none' : 'block' }} />
</div>
</section>
<section className="panel" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Change History
</h2>
{loadingHistory ? (
<p className="table-status" aria-busy="true">Loading</p>
) : history.length === 0 ? (
<p className="table-empty">No changes recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0' }}>
{history.map((entry) => (
<HistoryEntry key={entry.id} entry={entry} />
))}
</ul>
)}
</section>
</div>
);
}
function MetaItem({ label, children, wide }: { label: string; children: ReactNode; wide?: boolean }) {
return (
<div style={{ gridColumn: wide ? '1 / -1' : undefined }}>
<dt
style={{
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '0.25rem',
}}
>
{label}
</dt>
<dd style={{ margin: 0, fontSize: '0.9375rem' }}>{children}</dd>
</div>
);
}
function HistoryEntry({ entry }: { entry: ChangeLogEntry }) {
const label = CHANGE_TYPE_LABELS[entry.changeType] ?? entry.changeType;
const isCreated = entry.changeType === 'created';
const isUrlChange = entry.changeType === 'url_changed';
const isAliasChange = entry.changeType === 'alias_changed';
return (
<li
style={{
borderBottom: '1px solid var(--border)',
padding: '0.875rem 0',
display: 'grid',
gridTemplateColumns: '1.5rem 1fr auto',
gap: '0.5rem',
alignItems: 'start',
}}
>
<span
style={{
width: '0.625rem',
height: '0.625rem',
borderRadius: '50%',
background: isCreated ? 'var(--success)' : isUrlChange ? 'var(--accent)' : 'var(--muted)',
marginTop: '0.35rem',
flexShrink: 0,
display: 'block',
marginLeft: '0.45rem',
}}
/>
<div>
<p style={{ margin: 0, fontWeight: 600, fontSize: '0.875rem' }}>{label}</p>
{isUrlChange || isAliasChange ? (
<div style={{ marginTop: '0.25rem', fontSize: '0.8125rem' }}>
{entry.oldValue ? (
<p style={{ margin: '0.1rem 0', color: 'var(--danger)', textDecoration: 'line-through', wordBreak: 'break-all' }}>
{entry.oldValue}
</p>
) : null}
{entry.newValue ? (
<p style={{ margin: '0.1rem 0', color: 'var(--success)', wordBreak: 'break-all' }}>
{entry.newValue}
</p>
) : null}
</div>
) : null}
{entry.changeType === 'description_changed' ? (
<div style={{ marginTop: '0.25rem', fontSize: '0.8125rem' }}>
{entry.oldValue ? <p style={{ margin: '0.1rem 0', color: 'var(--muted)' }}>Was: {entry.oldValue}</p> : null}
{entry.newValue ? <p style={{ margin: '0.1rem 0', color: 'var(--text)' }}>Now: {entry.newValue}</p> : null}
</div>
) : null}
</div>
<time
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', marginTop: '0.2rem' }}
dateTime={entry.changedAt}
>
{new Date(entry.changedAt).toLocaleString()}
</time>
</li>
);
}
+7 -1
View File
@@ -43,6 +43,10 @@ class FakeD1Database {
return new FakeD1PreparedStatement(this, sql);
}
async batch(statements: FakeD1PreparedStatement[]): Promise<D1Result[]> {
return Promise.all(statements.map((statement) => statement.run()));
}
findSession(hash: string): SessionRow | null {
return this.sessions.find((candidate) => candidate.session_token_hash === hash) ?? null;
}
@@ -273,9 +277,11 @@ describe('my.heygo.cc private shortlinks', () => {
expectPrivateNoStoreHeaders(response);
expect(ctx.promises).toHaveLength(1);
await Promise.all(ctx.promises);
expect(db.runCalls).toHaveLength(1);
expect(db.runCalls).toHaveLength(2);
expect(db.runCalls[0].sql).toContain('click_count = click_count + 1');
expect(db.runCalls[0].params).toEqual(['link_a']);
expect(db.runCalls[1].sql).toContain('INSERT INTO click_daily');
expect(db.runCalls[1].params).toEqual(['link_a']);
});
it('isolates private aliases per user (user B resolves own link, not user A)', async () => {
+7 -1
View File
@@ -28,6 +28,10 @@ class FakeD1Database {
return new FakeD1PreparedStatement(this, sql);
}
async batch(statements: FakeD1PreparedStatement[]): Promise<D1Result[]> {
return Promise.all(statements.map((statement) => statement.run()));
}
findPublicActive(alias: string): Omit<LinkRow, 'scope' | 'status'> | null {
const row = this.rows.find((candidate) => {
return candidate.alias === alias && candidate.scope === 'public' && candidate.status === 'active';
@@ -130,9 +134,11 @@ describe('public heygo.cc shortlink redirects', () => {
expect(ctx.promises).toHaveLength(1);
await Promise.all(ctx.promises);
expect(db.runCalls).toHaveLength(1);
expect(db.runCalls).toHaveLength(2);
expect(db.runCalls[0].sql).toContain('UPDATE links SET click_count = click_count + 1');
expect(db.runCalls[0].params).toEqual(['link_1']);
expect(db.runCalls[1].sql).toContain('INSERT INTO click_daily');
expect(db.runCalls[1].params).toEqual(['link_1']);
});
it('renders a public custom link as escaped HTML', async () => {
+226 -6
View File
@@ -55,16 +55,33 @@ FROM links
WHERE scope='public' AND status='active'
ORDER BY updated_at DESC`;
const PRIVATE_LINK_BY_ID_QUERY = `SELECT ${LINK_COLUMNS}
const LINK_BY_ID_PRIVATE_QUERY = `SELECT ${LINK_COLUMNS}
FROM links
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'
LIMIT 1`;
const PUBLIC_LINK_BY_ID_QUERY = `SELECT ${LINK_COLUMNS}
const LINK_BY_ID_PUBLIC_QUERY = `SELECT ${LINK_COLUMNS}
FROM links
WHERE id=? AND scope='public' AND status='active'
LIMIT 1`;
const CLICK_STATS_QUERY = `SELECT day, count FROM click_daily
WHERE link_id=? AND day >= ?
ORDER BY day ASC`;
const CLICK_STATS_ALL_QUERY = `SELECT day, count FROM click_daily
WHERE link_id=?
ORDER BY day ASC`;
const CHANGE_LOG_QUERY = `SELECT id, change_type, old_value, new_value, changed_at
FROM link_change_logs
WHERE link_id=?
ORDER BY changed_at DESC
LIMIT 100`;
const CHANGE_LOG_INSERT = `INSERT INTO link_change_logs (id, link_id, change_type, old_value, new_value)
VALUES (?, ?, ?, ?, ?)`;
const PRIVATE_DUPLICATE_QUERY = `SELECT ${LINK_COLUMNS}
FROM links
WHERE scope='private' AND status != 'deleted' AND owner_user_id=? AND alias=?
@@ -115,6 +132,32 @@ export async function handleLinksApi(request: Request, env: Env): Promise<Respon
return methodNotAllowed();
}
if (pathname.match(/^\/api\/links\/private\/[^/]+$/) && request.method === 'GET') {
const id = decodePathSegment(pathname.split('/').pop()!);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getPrivateLink(request, env, id);
}
const privateLinkStatsMatch = pathname.match(/^\/api\/links\/private\/([^/]+)\/stats$/);
if (privateLinkStatsMatch && request.method === 'GET') {
const id = decodePathSegment(privateLinkStatsMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkStats(request, env, id, 'private');
}
const privateLinkHistoryMatch = pathname.match(/^\/api\/links\/private\/([^/]+)\/history$/);
if (privateLinkHistoryMatch && request.method === 'GET') {
const id = decodePathSegment(privateLinkHistoryMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkHistory(request, env, id, 'private');
}
const privateLinkMatch = pathname.match(/^\/api\/links\/private\/([^/]+)$/);
if (privateLinkMatch) {
const id = decodePathSegment(privateLinkMatch[1]);
@@ -144,6 +187,24 @@ export async function handleLinksApi(request: Request, env: Env): Promise<Respon
return methodNotAllowed();
}
const publicLinkStatsMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)\/stats$/);
if (publicLinkStatsMatch && request.method === 'GET') {
const id = decodePathSegment(publicLinkStatsMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkStats(request, env, id, 'public');
}
const publicLinkHistoryMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)\/history$/);
if (publicLinkHistoryMatch && request.method === 'GET') {
const id = decodePathSegment(publicLinkHistoryMatch[1]);
if (!id) {
return json({ error: 'Invalid id' }, { status: 400 });
}
return await getLinkHistory(request, env, id, 'public');
}
const publicLinkMatch = pathname.match(/^\/api\/admin\/public-links\/([^/]+)$/);
if (publicLinkMatch) {
const id = decodePathSegment(publicLinkMatch[1]);
@@ -175,6 +236,15 @@ async function listPrivateLinks(request: Request, env: Env): Promise<Response> {
return json({ links: (result.results ?? []).map(toLinkJson) });
}
async function getPrivateLink(request: Request, env: Env, id: string): Promise<Response> {
const user = await requireUser(request, env);
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
if (!link) {
return json({ error: 'Link not found' }, { status: 404 });
}
return json({ link: toLinkJson(link) });
}
async function listPublicLinks(env: Env): Promise<Response> {
const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all<LinkRow>();
return json({ links: (result.results ?? []).map(toLinkJson) });
@@ -206,7 +276,7 @@ async function createPublicLink(request: Request, env: Env): Promise<Response> {
async function updatePrivateLink(request: Request, env: Env, id: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await env.DB.prepare(PRIVATE_LINK_BY_ID_QUERY).bind(id, user.id).first<LinkRow>();
const existing = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
if (!existing) {
return json({ error: 'Link not found' }, { status: 404 });
}
@@ -221,6 +291,24 @@ async function updatePrivateLink(request: Request, env: Env, id: string): Promis
.bind(input.alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, user.id)
.run();
await Promise.all([
...(existing.alias !== input.alias
? [logLinkChange(env.DB, id, 'alias_changed', existing.alias, input.alias)]
: []),
...(existing.link_type !== input.linkType
? [logLinkChange(env.DB, id, 'type_changed', existing.link_type, input.linkType)]
: []),
...(existing.target_url !== input.targetUrl
? [logLinkChange(env.DB, id, 'url_changed', existing.target_url, input.targetUrl)]
: []),
...(existing.content_markdown !== input.contentMarkdown
? [logLinkChange(env.DB, id, 'content_changed', existing.content_markdown, input.contentMarkdown)]
: []),
...(existing.description !== input.description
? [logLinkChange(env.DB, id, 'description_changed', existing.description, input.description)]
: []),
]);
return json({
link: toLinkJson({
...existing,
@@ -236,7 +324,7 @@ async function updatePrivateLink(request: Request, env: Env, id: string): Promis
async function updatePublicLink(request: Request, env: Env, id: string): Promise<Response> {
await requireAdmin(request, env);
const existing = await env.DB.prepare(PUBLIC_LINK_BY_ID_QUERY).bind(id).first<LinkRow>();
const existing = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
if (!existing) {
return json({ error: 'Link not found' }, { status: 404 });
}
@@ -251,6 +339,24 @@ async function updatePublicLink(request: Request, env: Env, id: string): Promise
.bind(input.alias, input.linkType, input.targetUrl, input.contentMarkdown, input.description, id, 'public')
.run();
await Promise.all([
...(existing.alias !== input.alias
? [logLinkChange(env.DB, id, 'alias_changed', existing.alias, input.alias)]
: []),
...(existing.link_type !== input.linkType
? [logLinkChange(env.DB, id, 'type_changed', existing.link_type, input.linkType)]
: []),
...(existing.target_url !== input.targetUrl
? [logLinkChange(env.DB, id, 'url_changed', existing.target_url, input.targetUrl)]
: []),
...(existing.content_markdown !== input.contentMarkdown
? [logLinkChange(env.DB, id, 'content_changed', existing.content_markdown, input.contentMarkdown)]
: []),
...(existing.description !== input.description
? [logLinkChange(env.DB, id, 'description_changed', existing.description, input.description)]
: []),
]);
return json({
link: toLinkJson({
...existing,
@@ -266,7 +372,7 @@ async function updatePublicLink(request: Request, env: Env, id: string): Promise
async function deletePrivateLink(request: Request, env: Env, id: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await env.DB.prepare(PRIVATE_LINK_BY_ID_QUERY).bind(id, user.id).first<LinkRow>();
const existing = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
if (!existing) {
return json({ error: 'Link not found' }, { status: 404 });
}
@@ -277,7 +383,7 @@ async function deletePrivateLink(request: Request, env: Env, id: string): Promis
async function deletePublicLink(request: Request, env: Env, id: string): Promise<Response> {
await requireAdmin(request, env);
const existing = await env.DB.prepare(PUBLIC_LINK_BY_ID_QUERY).bind(id).first<LinkRow>();
const existing = await env.DB.prepare(LINK_BY_ID_PUBLIC_QUERY).bind(id).first<LinkRow>();
if (!existing) {
return json({ error: 'Link not found' }, { status: 404 });
}
@@ -309,6 +415,8 @@ async function insertLink(
)
.run();
await logLinkChange(env.DB, id, 'created', null, input.alias);
return toLinkJson({
id,
alias: input.alias,
@@ -426,6 +534,118 @@ function toLinkJson(row: LinkRow) {
};
}
type ChangeLogRow = {
id: string;
change_type: string;
old_value: string | null;
new_value: string | null;
changed_at: string;
};
function periodToStartDate(period: string): string | null {
const now = new Date();
if (period === '3m') {
now.setDate(now.getDate() - 90);
} else if (period === '6m') {
now.setDate(now.getDate() - 180);
} else if (period === '1y') {
now.setDate(now.getDate() - 365);
} else {
return null;
}
return now.toISOString().slice(0, 10);
}
async function getLinkStats(request: Request, env: Env, id: string, scope: 'private' | 'public'): Promise<Response> {
if (scope === 'private') {
const user = await requireUser(request, env);
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
if (!link) {
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 });
}
}
const url = new URL(request.url);
const period = url.searchParams.get('period') ?? '3m';
const startDate = periodToStartDate(period);
type StatRow = { day: string; count: number };
let rows: StatRow[];
if (startDate) {
const result = await env.DB.prepare(CLICK_STATS_QUERY).bind(id, startDate).all<StatRow>();
rows = result.results ?? [];
} else {
const result = await env.DB.prepare(CLICK_STATS_ALL_QUERY).bind(id).all<StatRow>();
rows = result.results ?? [];
}
return json({ stats: rows, period });
}
async function getLinkHistory(request: Request, env: Env, id: string, scope: 'private' | 'public'): Promise<Response> {
if (scope === 'private') {
const user = await requireUser(request, env);
const link = await env.DB.prepare(LINK_BY_ID_PRIVATE_QUERY).bind(id, user.id).first<LinkRow>();
if (!link) {
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 });
}
}
let rows: ChangeLogRow[] = [];
try {
const result = await env.DB.prepare(CHANGE_LOG_QUERY).bind(id).all<ChangeLogRow>();
rows = result.results ?? [];
} catch (error) {
if (!isMissingTableError(error, 'link_change_logs')) {
throw error;
}
}
return json({
history: rows.map((row) => ({
id: row.id,
changeType: row.change_type,
oldValue: row.old_value,
newValue: row.new_value,
changedAt: row.changed_at,
})),
});
}
async function logLinkChange(
db: D1Database,
linkId: string,
changeType: string,
oldValue: string | null,
newValue: string | null,
): Promise<void> {
try {
await db.prepare(CHANGE_LOG_INSERT)
.bind(crypto.randomUUID(), linkId, changeType, oldValue, newValue)
.run();
} catch (error) {
if (!isMissingTableError(error, 'link_change_logs')) {
throw error;
}
}
}
function isMissingTableError(error: unknown, tableName: string): boolean {
return error instanceof Error && error.message.toLowerCase().includes(`no such table: ${tableName}`);
}
function decodePathSegment(value: string): string | null {
try {
return decodeURIComponent(value);
+6 -1
View File
@@ -29,6 +29,8 @@ WHERE scope='private' AND status='active' AND owner_user_id=? AND alias=?
LIMIT 1`;
const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`;
const CLICK_DAILY_UPSERT = `INSERT INTO click_daily (link_id, day, count) VALUES (?, strftime('%Y-%m-%d', 'now'), 1)
ON CONFLICT (link_id, day) DO UPDATE SET count = count + 1`;
export function isHeygoPrivateHost(hostname: string, privateHost: string): boolean {
if (privateHost === '*') return true; // local dev: match any host (LAN access)
@@ -114,7 +116,10 @@ async function privateNotFoundOrLogin(request: Request, env: Env): Promise<Respo
async function recordClick(db: D1Database, linkId: string): Promise<void> {
try {
await db.prepare(CLICK_COUNT_UPDATE).bind(linkId).run();
await db.batch([
db.prepare(CLICK_COUNT_UPDATE).bind(linkId),
db.prepare(CLICK_DAILY_UPSERT).bind(linkId),
]);
} catch {
// Analytics must never block or break a private redirect/render.
}
+6 -1
View File
@@ -19,6 +19,8 @@ WHERE scope='public' AND status='active' AND alias=?
LIMIT 1`;
const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`;
const CLICK_DAILY_UPSERT = `INSERT INTO click_daily (link_id, day, count) VALUES (?, strftime('%Y-%m-%d', 'now'), 1)
ON CONFLICT (link_id, day) DO UPDATE SET count = count + 1`;
export function isReservedPublicPath(pathname: string): boolean {
return pathname === '/api' || pathname.startsWith('/api/')
@@ -111,7 +113,10 @@ export function parseAliasPath(pathname: string): ParsedAliasPath | null {
async function recordClick(db: D1Database, linkId: string): Promise<void> {
try {
await db.prepare(CLICK_COUNT_UPDATE).bind(linkId).run();
await db.batch([
db.prepare(CLICK_COUNT_UPDATE).bind(linkId),
db.prepare(CLICK_DAILY_UPSERT).bind(linkId),
]);
} catch {
// Analytics must never block or break a public redirect/render.
}