mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
Update UI
This commit is contained in:
@@ -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);
|
||||
Generated
+41
-11
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 } : {};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user