diff --git a/migrations/0002_link_detail.sql b/migrations/0002_link_detail.sql new file mode 100644 index 0000000..66d172c --- /dev/null +++ b/migrations/0002_link_detail.sql @@ -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); diff --git a/package-lock.json b/package-lock.json index cea1946..4c1b7ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 59beef9..384b739 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 3286b6c..70729a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => readRouteFromHash()); + const [linkDetailId, setLinkDetailId] = useState(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' ? : null} {route === 'profile' ? : null} {route === 'settings' ? : null} + {route === 'link-detail' && linkDetailId ? ( + { + if (linkDetailScope === 'public') { + navigate('home'); + } else { + navigate('my-links'); + } + }} + /> + ) : null}