diff --git a/src/App.tsx b/src/App.tsx index f8fcf55..f6714b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,17 +2,19 @@ import { useEffect, useState } from 'react'; import LoginPage from './routes/LoginPage'; import PrivateLinksPage from './routes/PrivateLinksPage'; import PublicLinksPage from './routes/PublicLinksPage'; +import AdminReviewPage from './routes/AdminReviewPage'; -type RouteId = 'private' | 'public' | 'login'; +type RouteId = 'private' | 'public' | 'admin' | 'login'; const ROUTES: readonly { id: RouteId; label: string }[] = [ { id: 'private', label: 'Private Links' }, { id: 'public', label: 'Public Directory' }, + { id: 'admin', label: 'Admin Review' }, { id: 'login', label: 'Login' }, ]; function readRouteFromHash(): RouteId { - const match = window.location.hash.match(/^#\/(private|public|login)/); + const match = window.location.hash.match(/^#\/(private|public|admin|login)/); return (match?.[1] as RouteId) ?? 'private'; } @@ -54,6 +56,7 @@ export default function App() {
{route === 'private' ? : null} {route === 'public' ? : null} + {route === 'admin' ? : null} {route === 'login' ? : null}
diff --git a/src/components/PromotionDialog.tsx b/src/components/PromotionDialog.tsx new file mode 100644 index 0000000..11f4a49 --- /dev/null +++ b/src/components/PromotionDialog.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import type { Link } from '../lib/api'; + +interface PromotionDialogProps { + /** Links selected for promotion in this submission. Exactly one must be chosen. */ + readonly links: Link[]; + readonly onSubmit: (input: { privateLinkId: string; proposedAlias: string; note?: string }) => Promise; + readonly onClose: () => void; +} + +const EMPTY_NOTE = ''; + +function defaultAlias(link: Link): string { + return link.alias; +} + +export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) { + const [selectedLinkId, setSelectedLinkId] = useState(() => links[0]?.id ?? ''); + const [proposedAlias, setProposedAlias] = useState(() => links[0]?.alias ?? ''); + const [note, setNote] = useState(EMPTY_NOTE); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Re-seed selection/alias when the set of links changes (e.g. user toggles + // checkboxes while the dialog is open). + useEffect(() => { + setSelectedLinkId((prev) => (links.some((l) => l.id === prev) ? prev : (links[0]?.id ?? ''))); + }, [links]); + + useEffect(() => { + const current = links.find((l) => l.id === selectedLinkId); + setProposedAlias(current ? defaultAlias(current) : ''); + }, [selectedLinkId, links]); + + const single = links.length === 1; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (submitting || !selectedLinkId) { + return; + } + setError(null); + setSubmitting(true); + try { + await onSubmit({ + privateLinkId: selectedLinkId, + proposedAlias: proposedAlias.trim(), + note: note.trim() ? note.trim() : undefined, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to submit promotion'); + } finally { + setSubmitting(false); + } + } + + if (links.length === 0) { + return ( +
+
+

Promote to public

+

Select at least one private link to promote.

+
+ +
+
+
+ ); + } + + return ( +
+
+
+

Promote to public directory

+ +
+ + {!single ? ( +
+ + + Choose which private link to submit for promotion. +
+ ) : ( +

+ Promoting {links[0].alias} + {links[0].targetUrl ? <> → {links[0].targetUrl} : null} +

+ )} + +
+ + setProposedAlias(e.target.value)} + required + maxLength={100} + autoComplete="off" + placeholder="public-alias" + disabled={submitting} + /> + The alias reviewers will publish this link under. +
+ +
+ +