diff --git a/src/App.tsx b/src/App.tsx index f28c145..f8fcf55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,65 @@ +import { useEffect, useState } from 'react'; +import LoginPage from './routes/LoginPage'; +import PrivateLinksPage from './routes/PrivateLinksPage'; +import PublicLinksPage from './routes/PublicLinksPage'; + +type RouteId = 'private' | 'public' | 'login'; + +const ROUTES: readonly { id: RouteId; label: string }[] = [ + { id: 'private', label: 'Private Links' }, + { id: 'public', label: 'Public Directory' }, + { id: 'login', label: 'Login' }, +]; + +function readRouteFromHash(): RouteId { + const match = window.location.hash.match(/^#\/(private|public|login)/); + return (match?.[1] as RouteId) ?? 'private'; +} + +function navigate(route: RouteId) { + if (readRouteFromHash() === route) { + return; + } + window.location.hash = `/${route}`; +} + export default function App() { + const [route, setRoute] = useState(() => readRouteFromHash()); + + useEffect(() => { + const onHashChange = () => setRoute(readRouteFromHash()); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + return ( -
-
-

Cloudflare Workers + React

-

Heygo shortlinks

-

- A fresh TypeScript scaffold for a React SPA backed by a Cloudflare - Worker API. -

- Check the Worker API health endpoint -
-
+
+
+ Heygo + +
+ +
+ {route === 'private' ? : null} + {route === 'public' ? : null} + {route === 'login' ? : null} +
+ + +
); } diff --git a/src/components/LinkForm.tsx b/src/components/LinkForm.tsx new file mode 100644 index 0000000..e44c860 --- /dev/null +++ b/src/components/LinkForm.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react'; +import type { Link, LinkInput, LinkType } from '../lib/api'; + +interface LinkFormProps { + /** Existing link when editing; omit for create mode. */ + readonly initial?: Link; + /** Submit handler; receives normalized input. Throw to surface an error. */ + readonly onSubmit: (input: LinkInput) => Promise; + readonly onCancel?: () => void; + readonly submitLabel?: string; + readonly disabled?: boolean; +} + +const EMPTY_INPUT: LinkInput = { + alias: '', + linkType: 'redirect', + targetUrl: '', + contentMarkdown: '', + description: '', +}; + +function toFormInput(link?: Link): LinkInput { + if (!link) { + return { ...EMPTY_INPUT }; + } + return { + alias: link.alias, + linkType: link.linkType, + targetUrl: link.targetUrl ?? '', + contentMarkdown: link.contentMarkdown ?? '', + description: link.description ?? '', + }; +} + +export default function LinkForm({ + initial, + onSubmit, + onCancel, + submitLabel, + disabled = false, +}: LinkFormProps) { + const [input, setInput] = useState(() => toFormInput(initial)); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Re-seed when switching between links (e.g. edit target changes). + useEffect(() => { + setInput(toFormInput(initial)); + setError(null); + }, [initial?.id]); + + const linkType: LinkType = input.linkType; + + function update(field: Field, value: LinkInput[Field]) { + setInput((prev) => ({ ...prev, [field]: value })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (submitting || disabled) { + return; + } + setError(null); + setSubmitting(true); + try { + const targetUrl = input.linkType === 'redirect' ? (input.targetUrl?.trim() || null) : null; + await onSubmit({ + alias: input.alias.trim(), + linkType: input.linkType, + targetUrl, + contentMarkdown: input.linkType === 'custom' ? input.contentMarkdown : undefined, + description: input.description?.trim() ? input.description.trim() : undefined, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save link'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ + update('alias', e.target.value)} + required + maxLength={100} + autoComplete="off" + placeholder="my-link" + disabled={submitting || disabled} + /> + Letters, numbers, hyphens, underscores. Case-insensitive. +
+ +
+ Link type + + +
+ + {linkType === 'redirect' ? ( +
+ + update('targetUrl', e.target.value)} + required + placeholder="https://example.com/search?q={query}" + disabled={submitting || disabled} + /> + + Supports parameterized templates. Use {'{query}'} placeholders, e.g. + https://example.com/search?q={'{query}'} + then visit /alias/foo?bar=baz to substitute. + +
+ ) : ( +
+ +