mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
feat: add link management UI
This commit is contained in:
+61
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user