feat: add link management UI

This commit is contained in:
Hermes Agent
2026-06-20 12:40:09 +10:00
parent 7f939bef89
commit d1294a92d7
10 changed files with 1386 additions and 32 deletions
+61 -11
View File
@@ -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<RouteId>(() => readRouteFromHash());
useEffect(() => {
const onHashChange = () => setRoute(readRouteFromHash());
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">Cloudflare Workers + React</p>
<h1>Heygo shortlinks</h1>
<p>
A fresh TypeScript scaffold for a React SPA backed by a Cloudflare
Worker API.
</p>
<a href="/api/health">Check the Worker API health endpoint</a>
</section>
</main>
<div className="app-shell">
<header className="app-bar">
<a className="brand" href="#/private">Heygo</a>
<nav className="tabs" aria-label="Primary">
{ROUTES.map((entry) => (
<a
key={entry.id}
href={`#/${entry.id}`}
className={`tab${route === entry.id ? ' is-active' : ''}`}
aria-current={route === entry.id ? 'page' : undefined}
onClick={() => navigate(entry.id)}
>
{entry.label}
</a>
))}
</nav>
</header>
<main className="app-main">
{route === 'private' ? <PrivateLinksPage /> : null}
{route === 'public' ? <PublicLinksPage /> : null}
{route === 'login' ? <LoginPage /> : null}
</main>
<footer className="app-footer">
<a href="/api/health">API health</a>
</footer>
</div>
);
}
+187
View File
@@ -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<void>;
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<LinkInput>(() => toFormInput(initial));
const [error, setError] = useState<string | null>(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 extends keyof LinkInput>(field: Field, value: LinkInput[Field]) {
setInput((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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 (
<form className="link-form" onSubmit={handleSubmit} aria-label={initial ? 'Edit link' : 'Create link'}>
<div className="field">
<label htmlFor="link-alias">Alias</label>
<input
id="link-alias"
name="alias"
value={input.alias}
onChange={(e) => update('alias', e.target.value)}
required
maxLength={100}
autoComplete="off"
placeholder="my-link"
disabled={submitting || disabled}
/>
<small className="hint">Letters, numbers, hyphens, underscores. Case-insensitive.</small>
</div>
<fieldset className="field" disabled={submitting || disabled}>
<legend>Link type</legend>
<label className="radio">
<input
type="radio"
name="linkType"
value="redirect"
checked={linkType === 'redirect'}
onChange={() => update('linkType', 'redirect')}
/>
Redirect URL
</label>
<label className="radio">
<input
type="radio"
name="linkType"
value="custom"
checked={linkType === 'custom'}
onChange={() => update('linkType', 'custom')}
/>
Custom markdown page
</label>
</fieldset>
{linkType === 'redirect' ? (
<div className="field">
<label htmlFor="link-target">Target URL</label>
<input
id="link-target"
name="targetUrl"
type="url"
value={input.targetUrl ?? ''}
onChange={(e) => update('targetUrl', e.target.value)}
required
placeholder="https://example.com/search?q={query}"
disabled={submitting || disabled}
/>
<small className="hint">
Supports parameterized templates. Use <code>{'{query}'}</code> placeholders, e.g.
<code> https://example.com/search?q={'{query}'} </code>
then visit <code>/alias/foo?bar=baz</code> to substitute.
</small>
</div>
) : (
<div className="field">
<label htmlFor="link-content">Markdown content</label>
<textarea
id="link-content"
name="contentMarkdown"
value={input.contentMarkdown ?? ''}
onChange={(e) => update('contentMarkdown', e.target.value)}
required
rows={6}
placeholder="# Hello world"
disabled={submitting || disabled}
/>
</div>
)}
<div className="field">
<label htmlFor="link-description">Description (optional)</label>
<input
id="link-description"
name="description"
value={input.description ?? ''}
onChange={(e) => update('description', e.target.value)}
maxLength={500}
placeholder="What this link is for"
disabled={submitting || disabled}
/>
</div>
{error ? (
<p className="form-error" role="alert">{error}</p>
) : null}
<div className="form-actions">
<button type="submit" disabled={submitting || disabled}>
{submitting ? 'Saving…' : (submitLabel ?? 'Save')}
</button>
{onCancel ? (
<button type="button" onClick={onCancel} disabled={submitting || disabled}>
Cancel
</button>
) : null}
</div>
</form>
);
}
+124
View File
@@ -0,0 +1,124 @@
import type { Link } from '../lib/api';
interface LinkTableProps {
readonly links: Link[];
readonly loading?: boolean;
readonly error?: string | null;
readonly emptyMessage?: string;
/** When true (private links), render selection checkboxes. */
readonly selectable?: boolean;
readonly selectedIds?: ReadonlySet<string>;
readonly onToggleSelect?: (id: string) => void;
readonly onSelectAll?: (ids: string[]) => void;
readonly onEdit?: (link: Link) => void;
readonly onDelete?: (link: Link) => void;
/** Render action buttons (edit/delete). Defaults to true. */
readonly actions?: boolean;
}
export default function LinkTable({
links,
loading = false,
error = null,
emptyMessage = 'No links yet.',
selectable = false,
selectedIds,
onToggleSelect,
onSelectAll,
onEdit,
onDelete,
actions = true,
}: LinkTableProps) {
if (loading) {
return <p className="table-status" aria-busy="true">Loading links</p>;
}
if (error) {
return <p className="table-error" role="alert">{error}</p>;
}
if (links.length === 0) {
return <p className="table-empty">{emptyMessage}</p>;
}
const allIds = links.map((link) => link.id);
const allSelected = selectable && selectedIds != null && allIds.length > 0 && allIds.every((id) => selectedIds.has(id));
return (
<table className="link-table">
<thead>
<tr>
{selectable ? (
<th scope="col" className="col-select">
<input
type="checkbox"
aria-label={allSelected ? 'Deselect all links' : 'Select all links'}
checked={allSelected}
onChange={() => onSelectAll?.(allSelected ? [] : allIds)}
/>
</th>
) : null}
<th scope="col">Alias</th>
<th scope="col">Type</th>
<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}
</tr>
</thead>
<tbody>
{links.map((link) => {
const selected = selectable && selectedIds?.has(link.id) === true;
return (
<tr key={link.id} className={selected ? 'is-selected' : undefined}>
{selectable ? (
<td className="col-select">
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onChange={() => onToggleSelect?.(link.id)}
/>
</td>
) : null}
<td className="col-alias">
<code>{link.alias}</code>
{link.description ? <small className="row-description">{link.description}</small> : null}
</td>
<td>{link.linkType === 'custom' ? 'Custom' : 'Redirect'}</td>
<td className="col-target">
{link.linkType === 'redirect' ? (
link.targetUrl ? (
<a href={link.targetUrl} target="_blank" rel="noreferrer noopener" className="truncate">
{link.targetUrl}
</a>
) : (
<span className="muted"></span>
)
) : (
<span className="muted">markdown</span>
)}
</td>
<td className="col-clicks">{link.clickCount}</td>
<td className="col-updated">
<time dateTime={link.updatedAt}>{new Date(link.updatedAt).toLocaleString()}</time>
</td>
{actions ? (
<td className="col-actions">
{onEdit ? (
<button type="button" className="link-action" onClick={() => onEdit(link)}>Edit</button>
) : null}
{onDelete ? (
<button type="button" className="link-action link-action--danger" onClick={() => onDelete(link)}>
Delete
</button>
) : null}
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Login provider buttons. OAuth callback handlers are not implemented yet
// (Task 10 scope is UI only); the buttons are real anchor links to the
// provider auth start endpoints so they light up once the OAuth flow lands.
// They are not fake/JS auth — clicking performs a full-page navigation.
interface ProviderInfo {
readonly id: string;
readonly label: string;
readonly href: string;
}
const PROVIDERS: readonly ProviderInfo[] = [
{ id: 'google', label: 'Continue with Google', href: '/api/auth/google' },
{ id: 'github', label: 'Continue with GitHub', href: '/api/auth/github' },
];
interface ProviderButtonsProps {
readonly disabled?: boolean;
}
export default function ProviderButtons({ disabled = false }: ProviderButtonsProps) {
return (
<div className="provider-buttons" role="group" aria-label="Sign in with a provider">
{PROVIDERS.map((provider) => (
<a
key={provider.id}
className={`provider-button provider-button--${provider.id}${disabled ? ' is-disabled' : ''}`}
href={disabled ? undefined : provider.href}
aria-disabled={disabled || undefined}
data-provider={provider.id}
>
<span className="provider-button__label">{provider.label}</span>
</a>
))}
<p className="provider-note">
OAuth sign-in is not wired up yet. These buttons start the provider flow once the
callback handlers ship.
</p>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
// Typed API client for the Heygo link management UI.
// All requests send credentials (session cookie) so authenticated endpoints
// work once OAuth/session login is implemented in a later task.
export type LinkType = 'redirect' | 'custom';
export type LinkScope = 'public' | 'private';
export type LinkStatus = 'active' | 'archived' | 'deleted';
export interface Link {
id: string;
alias: string;
scope: LinkScope;
linkType: LinkType;
targetUrl: string | null;
contentMarkdown: string | null;
description: string | null;
ownerUserId: string | null;
clickCount: number;
status: LinkStatus;
createdAt: string;
updatedAt: string;
}
export interface LinkInput {
alias: string;
linkType: LinkType;
targetUrl?: string | null;
contentMarkdown?: string;
description?: string;
}
export interface LinkListResponse {
links: Link[];
}
export interface LinkMutationResponse {
link: Link;
}
export interface DeleteResponse {
ok: true;
}
export interface ApiError {
error: string;
}
export class ApiClientError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiClientError';
this.status = status;
}
}
async function parseErrorMessage(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText || 'Request failed'}`;
try {
const body = (await response.json()) as Partial<ApiError>;
return body?.error ?? fallback;
} catch {
return fallback;
}
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const response = await fetch(path, {
...options,
credentials: 'include',
headers: {
...(options.body !== undefined ? { 'content-type': 'application/json' } : {}),
...options.headers,
},
});
if (!response.ok) {
throw new ApiClientError(response.status, await parseErrorMessage(response));
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
function jsonBody(input: LinkInput): string {
return JSON.stringify({
alias: input.alias,
linkType: input.linkType,
targetUrl: input.targetUrl ?? null,
contentMarkdown: input.contentMarkdown ?? null,
description: input.description ?? null,
});
}
// ---- Private links (owner-scoped) ----
export function listPrivateLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/private');
}
export function createPrivateLink(input: LinkInput): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>('/api/links/private', {
method: 'POST',
body: jsonBody(input),
});
}
export function updatePrivateLink(id: string, input: LinkInput): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>(`/api/links/private/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: jsonBody(input),
});
}
export function deletePrivateLink(id: string): Promise<DeleteResponse> {
return request<DeleteResponse>(`/api/links/private/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
// ---- Public directory (read-only for normal users) ----
export function listPublicLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/public');
}
// ---- Admin public-link management ----
export function createPublicLink(input: LinkInput): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>('/api/admin/public-links', {
method: 'POST',
body: jsonBody(input),
});
}
export function updatePublicLink(id: string, input: LinkInput): Promise<LinkMutationResponse> {
return request<LinkMutationResponse>(`/api/admin/public-links/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: jsonBody(input),
});
}
export function deletePublicLink(id: string): Promise<DeleteResponse> {
return request<DeleteResponse>(`/api/admin/public-links/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
// ---- Pure helper: build a preview URL for parameterized redirect links ----
// Returns null when the target URL is not usable as a template.
export function buildPreviewUrl(targetUrl: string | null, params: Record<string, string> = {}): string | null {
if (!targetUrl) {
return null;
}
let preview = targetUrl;
for (const [key, value] of Object.entries(params)) {
preview = preview.replaceAll(`{${key}}`, encodeURIComponent(value));
}
// If template placeholders remain, swap them with a human placeholder so the
// preview is still readable instead of containing raw `{query}` braces.
preview = preview.replace(/\{[^}]+\}/g, 'example');
return preview;
}
+19
View File
@@ -0,0 +1,19 @@
import ProviderButtons from '../components/ProviderButtons';
export default function LoginPage() {
return (
<section className="panel login-panel">
<h1>Sign in to Heygo</h1>
<p className="muted">
Manage your private shortlinks and browse the public directory. Private link management
requires a signed-in session.
</p>
<ProviderButtons />
<hr className="divider" />
<p className="muted">
Don't want to sign in? You can still browse the{' '}
<a href="#/public">public directory</a>.
</p>
</section>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useState } from 'react';
import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable';
import {
ApiClientError,
type Link,
type LinkInput,
createPrivateLink,
deletePrivateLink,
listPrivateLinks,
updatePrivateLink,
} from '../lib/api';
export default function PrivateLinksPage() {
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Link | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await listPrivateLinks();
setLinks(result.links ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load links');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function selectAll(ids: string[]) {
setSelected(new Set(ids));
}
async function handleCreate(input: LinkInput) {
try {
const result = await createPrivateLink(input);
setLinks((prev) => [result.link, ...prev]);
setShowCreate(false);
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to create link');
}
}
async function handleUpdate(input: LinkInput) {
if (!editing) {
return;
}
try {
const result = await updatePrivateLink(editing.id, input);
setLinks((prev) => prev.map((link) => (link.id === editing.id ? result.link : link)));
setEditing(null);
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to update link');
}
}
async function handleDelete(link: Link) {
if (!window.confirm(`Delete the link "${link.alias}"? This cannot be undone.`)) {
return;
}
try {
await deletePrivateLink(link.id);
setLinks((prev) => prev.filter((item) => item.id !== link.id));
setSelected((prev) => {
const next = new Set(prev);
next.delete(link.id);
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete link');
}
}
return (
<section className="panel">
<header className="panel-header">
<div>
<h1>Private links</h1>
<p className="muted">Your personal shortlinks. Only visible to you.</p>
</div>
<div className="panel-actions">
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button>
<button type="button" onClick={() => { setEditing(null); setShowCreate((v) => !v); }}>
{showCreate ? 'Close form' : 'New link'}
</button>
</div>
</header>
{selected.size > 0 ? (
<p className="selection-bar" role="status">
{selected.size} selected. Promotion to public links is coming in a later task.
</p>
) : null}
{showCreate ? (
<div className="form-card">
<h2>Create link</h2>
<LinkForm
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
submitLabel="Create"
/>
</div>
) : null}
{editing ? (
<div className="form-card">
<h2>Edit <code>{editing.alias}</code></h2>
<LinkForm
initial={editing}
onSubmit={handleUpdate}
onCancel={() => setEditing(null)}
submitLabel="Save changes"
/>
</div>
) : null}
<LinkTable
links={links}
loading={loading}
error={error}
emptyMessage="You have no private links yet. Create one to get started."
selectable
selectedIds={selected}
onToggleSelect={toggleSelect}
onSelectAll={selectAll}
onEdit={(link) => { setShowCreate(false); setEditing(link); }}
onDelete={handleDelete}
/>
</section>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react';
import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable';
import {
ApiClientError,
type Link,
type LinkInput,
createPublicLink,
deletePublicLink,
listPublicLinks,
updatePublicLink,
} from '../lib/api';
export default function PublicLinksPage() {
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [adminOpen, setAdminOpen] = useState(false);
const [adminError, setAdminError] = useState<string | null>(null);
const [editing, setEditing] = useState<Link | null>(null);
const [showCreate, setShowCreate] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await listPublicLinks();
setLinks(result.links ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load public links');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function reloadAdmin() {
// The public list endpoint already covers admin-visible public links.
await refresh();
}
async function handleCreate(input: LinkInput) {
setAdminError(null);
try {
const result = await createPublicLink(input);
setLinks((prev) => [result.link, ...prev]);
setShowCreate(false);
} catch (err) {
setAdminError(err instanceof ApiClientError ? err.message : 'Failed to create public link');
throw err;
}
}
async function handleUpdate(input: LinkInput) {
if (!editing) {
return;
}
setAdminError(null);
try {
const result = await updatePublicLink(editing.id, input);
setLinks((prev) => prev.map((link) => (link.id === editing.id ? result.link : link)));
setEditing(null);
} catch (err) {
setAdminError(err instanceof ApiClientError ? err.message : 'Failed to update public link');
throw err;
}
}
async function handleDelete(link: Link) {
if (!window.confirm(`Delete the public link "${link.alias}"?`)) {
return;
}
setAdminError(null);
try {
await deletePublicLink(link.id);
setLinks((prev) => prev.filter((item) => item.id !== link.id));
} catch (err) {
setAdminError(err instanceof Error ? err.message : 'Failed to delete public link');
}
}
return (
<section className="panel">
<header className="panel-header">
<div>
<h1>Public directory</h1>
<p className="muted">Public shortlinks anyone can resolve.</p>
</div>
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button>
</header>
<LinkTable
links={links}
loading={loading}
error={error}
emptyMessage="No public links yet."
actions={false}
/>
<details className="admin-tools" onToggle={(e) => setAdminOpen((e.target as HTMLDetailsElement).open)}>
<summary>Admin tools (requires admin session)</summary>
<p className="muted">
Creating, editing, and deleting public links calls <code>/api/admin/public-links</code>.
Without an admin session these actions return 403.
</p>
{adminOpen ? (
<div className="admin-toolbar">
<button type="button" onClick={() => { setEditing(null); setShowCreate((v) => !v); }}>
{showCreate ? 'Close form' : 'New public link'}
</button>
<button type="button" onClick={() => void reloadAdmin()}>Reload</button>
</div>
) : null}
{adminError ? <p className="form-error" role="alert">{adminError}</p> : null}
{showCreate && adminOpen ? (
<div className="form-card">
<h2>Create public link</h2>
<LinkForm
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
submitLabel="Create"
/>
</div>
) : null}
{editing && adminOpen ? (
<div className="form-card">
<h2>Edit <code>{editing.alias}</code></h2>
<LinkForm
initial={editing}
onSubmit={handleUpdate}
onCancel={() => setEditing(null)}
submitLabel="Save changes"
/>
</div>
) : null}
{adminOpen ? (
<LinkTable
links={links}
loading={loading}
emptyMessage="No public links."
onEdit={(link) => { setShowCreate(false); setEditing(link); }}
onDelete={handleDelete}
/>
) : null}
</details>
</section>
);
}
+317 -21
View File
@@ -4,44 +4,340 @@
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--border: #dde4ef;
--border-strong: #c2cdde;
--muted: #6b7280;
--accent: #4354d8;
--accent-soft: #eef0fc;
--danger: #b91c1c;
--danger-soft: #fee2e2;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0;
}
a {
color: var(--accent);
font-weight: 600;
}
code {
background: var(--accent-soft);
border-radius: 4px;
padding: 0.05em 0.35em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
.muted { color: var(--muted); }
/* ---- Layout ---- */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-bar {
align-items: center;
background: white;
border-bottom: 1px solid var(--border);
display: flex;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-size: 1.25rem;
font-weight: 800;
text-decoration: none;
}
.tabs {
display: flex;
gap: 0.25rem;
}
.tab {
border-radius: 8px;
color: var(--muted);
font-weight: 600;
padding: 0.4rem 0.75rem;
text-decoration: none;
}
.tab:hover { background: var(--accent-soft); color: var(--accent); }
.tab.is-active {
background: var(--accent);
color: white;
}
.app-main {
align-items: center;
display: flex;
min-height: 100vh;
justify-content: center;
padding: 2rem;
flex: 1;
flex-direction: column;
gap: 1.5rem;
padding: 2rem 1.5rem;
}
.hero {
background: white;
border: 1px solid #dde4ef;
border-radius: 24px;
box-shadow: 0 24px 80px rgb(23 32 51 / 12%);
max-width: 720px;
padding: 3rem;
}
.eyebrow {
color: #5b6ee1;
.app-footer {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.85rem;
padding: 1rem 1.5rem;
text-align: center;
}
/* ---- Panels / cards ---- */
.panel {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 40px rgb(23 32 51 / 6%);
max-width: 920px;
padding: 1.5rem;
width: 100%;
}
.login-panel {
max-width: 460px;
text-align: left;
}
.panel-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.panel-header h1 { font-size: 1.5rem; margin: 0; }
.panel-header .muted { margin: 0.25rem 0 0; }
.panel-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.form-card {
background: #fbfcfe;
border: 1px solid var(--border);
border-radius: var(--radius);
margin: 1rem 0;
padding: 1rem 1.25rem;
}
.form-card h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
/* ---- Provider buttons ---- */
.provider-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.provider-button {
align-items: center;
border: 1px solid var(--border-strong);
border-radius: 10px;
display: flex;
font-weight: 600;
justify-content: center;
padding: 0.65rem 1rem;
text-decoration: none;
color: #172033;
}
.provider-button:hover { background: #f1f4fb; }
.provider-button.is-disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.7;
}
.provider-note {
color: var(--muted);
font-size: 0.8rem;
margin: 0.5rem 0 0;
}
/* ---- Forms ---- */
.link-form { display: flex; flex-direction: column; gap: 1rem; }
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field legend { font-weight: 600; padding: 0; }
.radio {
align-items: center;
display: inline-flex;
gap: 0.4rem;
font-weight: 500;
}
.hint { color: var(--muted); font-size: 0.8rem; }
.link-form input[type="text"],
.link-form input[type="url"],
.link-form input:not([type]),
.link-form textarea {
border: 1px solid var(--border-strong);
border-radius: 8px;
font: inherit;
padding: 0.5rem 0.65rem;
width: 100%;
}
.link-form input:focus,
.link-form textarea:focus {
border-color: var(--accent);
outline: 2px solid var(--accent-soft);
}
.form-actions { display: flex; gap: 0.5rem; }
button {
background: var(--accent);
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0.5rem 0.9rem;
}
button:hover { background: #3543b3; }
button:disabled { background: #aab2c8; cursor: not-allowed; }
button.link-action {
background: white;
border: 1px solid var(--border-strong);
color: #172033;
font-size: 0.85rem;
padding: 0.25rem 0.55rem;
}
button.link-action:hover { background: var(--accent-soft); }
button.link-action--danger {
border-color: #f3b4b4;
color: var(--danger);
}
button.link-action--danger:hover { background: var(--danger-soft); }
.form-error {
background: var(--danger-soft);
border-radius: 8px;
color: var(--danger);
font-size: 0.9rem;
margin: 0;
padding: 0.5rem 0.75rem;
}
/* ---- Table ---- */
.link-table {
border-collapse: collapse;
width: 100%;
}
.link-table th,
.link-table td {
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.6rem;
text-align: left;
vertical-align: top;
font-size: 0.92rem;
}
.link-table th {
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1 {
font-size: clamp(2.5rem, 8vw, 5rem);
line-height: 0.95;
margin: 0 0 1rem;
.link-table tr.is-selected { background: var(--accent-soft); }
.col-select { width: 2rem; }
.col-actions { white-space: nowrap; }
.col-actions button + button { margin-left: 0.25rem; }
.row-description { color: var(--muted); display: block; font-size: 0.8rem; }
.truncate {
display: inline-block;
max-width: 24rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
a {
color: #4354d8;
.table-status,
.table-empty,
.table-error {
color: var(--muted);
padding: 1rem 0;
}
.table-error { color: var(--danger); }
.selection-bar {
background: var(--accent-soft);
border-radius: 8px;
color: var(--accent);
font-size: 0.9rem;
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
}
/* ---- Admin tools ---- */
.admin-tools {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-top: 1.5rem;
padding: 0.75rem 1rem;
}
.admin-tools > summary {
cursor: pointer;
font-weight: 700;
}
.admin-toolbar {
display: flex;
gap: 0.5rem;
margin: 0.75rem 0;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.panel { padding: 1rem; }
.link-table { font-size: 0.85rem; }
.truncate { max-width: 12rem; }
}
+157
View File
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
buildPreviewUrl,
createPrivateLink,
deletePrivateLink,
listPrivateLinks,
listPublicLinks,
updatePrivateLink,
} from '../src/lib/api';
const originalFetch = globalThis.fetch;
function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => {
status?: number;
body?: unknown;
statusText?: string;
}) {
const calls: { path: string; init?: RequestInit }[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = typeof input === 'string' ? input : new URL(input.toString()).pathname;
calls.push({ path, init });
const { status = 200, body = {}, statusText = 'OK' } = responder(input, init);
return new Response(JSON.stringify(body), {
status,
statusText,
headers: { 'content-type': 'application/json' },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return {
calls,
fetchMock,
};
}
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('api client', () => {
it('listPrivateLinks sends GET with credentials to /api/links/private', async () => {
const { calls } = mockFetch(() => ({
body: { links: [{ id: 'link_1', alias: 'docs', scope: 'private', linkType: 'redirect' }] },
}));
const result = await listPrivateLinks();
expect(calls).toHaveLength(1);
expect(calls[0].path).toBe('/api/links/private');
expect(calls[0].init?.method).toBeUndefined();
expect(calls[0].init?.credentials).toBe('include');
expect(result.links).toHaveLength(1);
expect(result.links[0].alias).toBe('docs');
});
it('createPrivateLink POSTs a normalized JSON body', async () => {
const { calls } = mockFetch((_input, init) => ({
status: 201,
body: { link: { id: 'new', alias: 'docs', scope: 'private', linkType: 'redirect' } },
}));
const result = await createPrivateLink({
alias: 'Docs',
linkType: 'redirect',
targetUrl: 'https://example.com',
description: ' spaced ',
});
expect(calls[0].init?.method).toBe('POST');
expect(calls[0].init?.credentials).toBe('include');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({
alias: 'Docs',
linkType: 'redirect',
targetUrl: 'https://example.com',
contentMarkdown: null,
description: ' spaced ',
});
expect(result.link.id).toBe('new');
});
it('updatePrivateLink targets the link id with PATCH', async () => {
const { calls } = mockFetch(() => ({
body: { link: { id: 'link_1', alias: 'renamed', scope: 'private', linkType: 'custom' } },
}));
await updatePrivateLink('link_1', { alias: 'renamed', linkType: 'custom', contentMarkdown: '# Hi' });
expect(calls[0].path).toBe('/api/links/private/link_1');
expect(calls[0].init?.method).toBe('PATCH');
const body = JSON.parse(calls[0].init?.body as string);
expect(body.linkType).toBe('custom');
expect(body.targetUrl).toBeNull();
expect(body.contentMarkdown).toBe('# Hi');
});
it('deletePrivateLink issues a DELETE request', async () => {
const { calls } = mockFetch(() => ({ body: { ok: true } }));
const result = await deletePrivateLink('link_1');
expect(calls[0].path).toBe('/api/links/private/link_1');
expect(calls[0].init?.method).toBe('DELETE');
expect(result.ok).toBe(true);
});
it('listPublicLinks reads /api/links/public', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks();
expect(calls[0].path).toBe('/api/links/public');
});
it('encodes link ids containing special path segments', async () => {
const { calls } = mockFetch(() => ({ body: { ok: true } }));
await deletePrivateLink('link/with/slash');
expect(calls[0].path).toBe('/api/links/private/link%2Fwith%2Fslash');
});
it('throws ApiClientError with the server error message on non-ok responses', async () => {
mockFetch(() => ({ status: 409, body: { error: 'Alias already exists' } }));
await expect(createPrivateLink({ alias: 'dup', linkType: 'redirect', targetUrl: 'https://x' }))
.rejects.toMatchObject({ name: 'ApiClientError', status: 409, message: 'Alias already exists' });
});
it('falls back to status text when the body has no error field', async () => {
mockFetch(() => ({ status: 500, body: {}, statusText: 'Internal Server Error' }));
await expect(listPrivateLinks()).rejects.toMatchObject({
name: 'ApiClientError',
status: 500,
message: '500 Internal Server Error',
});
});
});
describe('buildPreviewUrl', () => {
it('returns null for empty target URLs', () => {
expect(buildPreviewUrl(null)).toBeNull();
expect(buildPreviewUrl('')).toBeNull();
});
it('substitutes known template placeholders with encoded values', () => {
const url = 'https://example.com/search?q={query}&lang={lang}';
expect(buildPreviewUrl(url, { query: 'go links', lang: 'en' }))
.toBe('https://example.com/search?q=go%20links&lang=en');
});
it('replaces leftover placeholders with a readable token', () => {
expect(buildPreviewUrl('https://example.com/{region}/view')).toBe('https://example.com/example/view');
});
});