This commit is contained in:
2026-06-20 21:16:06 +10:00
parent 7cfe499be4
commit 22cd7ede24
4 changed files with 384 additions and 63 deletions
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import { PencilLineIcon } from 'lucide-react';
import {
ApiClientError,
type PromotionSubmission,
type SubmissionStatus,
listMySubmissions,
promoteLink,
} from '../lib/api';
import ResubmitDialog from './ResubmitDialog';
const STATUS_LABELS: Record<SubmissionStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
needs_changes: 'Needs changes',
};
interface MySubmissionsListProps {
readonly onSubmitted?: () => void;
}
export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProps) {
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resubmitting, setResubmitting] = useState<PromotionSubmission | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await listMySubmissions();
setSubmissions(result.submissions ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load submissions');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleResubmit(input: { privateLinkId: string; proposedAlias: string; note?: string }) {
if (!resubmitting) {
return;
}
try {
await promoteLink(input);
setResubmitting(null);
await refresh();
onSubmitted?.();
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to resubmit promotion');
}
}
return (
<div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
{loading ? (
<p className="table-status" aria-busy="true">Loading submissions</p>
) : submissions.length === 0 ? (
<p className="table-empty">You have not submitted any links for promotion yet.</p>
) : (
<table className="link-table review-table">
<thead>
<tr>
<th scope="col">Proposed alias</th>
<th scope="col">Private link</th>
<th scope="col">Submitted</th>
<th scope="col">Status</th>
<th scope="col" className="col-actions">Actions</th>
</tr>
</thead>
<tbody>
{submissions.map((submission) => (
<tr key={submission.id}>
<td>
<span className="alias-pill">#{submission.proposedAlias}</span>
{submission.note ? <small className="row-description">{submission.note}</small> : null}
{submission.rejectionReason ? (
<small className="row-description review-reason">Reason: {submission.rejectionReason}</small>
) : null}
</td>
<td>
{submission.privateLinkAlias
? <span className="alias-pill">#{submission.privateLinkAlias}</span>
: <span className="muted"></span>}
{submission.privateLinkTargetUrl ? (
<small className="row-description truncate">{submission.privateLinkTargetUrl}</small>
) : null}
</td>
<td>
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</td>
<td>
<span className={`status-badge status-${submission.status}`}>{STATUS_LABELS[submission.status]}</span>
</td>
<td className="col-actions">
{submission.status === 'needs_changes' ? (
<button
type="button"
className="link-action link-action--confirm"
onClick={() => setResubmitting(submission)}
>
<PencilLineIcon aria-hidden="true" size={14} />
<span>Edit &amp; resubmit</span>
</button>
) : (
<span className="muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{resubmitting ? (
<ResubmitDialog
submission={resubmitting}
onSubmit={handleResubmit}
onClose={() => setResubmitting(null)}
/>
) : null}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react';
import type { PromotionSubmission } from '../lib/api';
interface ResubmitDialogProps {
readonly submission: PromotionSubmission;
readonly onSubmit: (input: { privateLinkId: string; proposedAlias: string; note?: string }) => Promise<void>;
readonly onClose: () => void;
}
export default function ResubmitDialog({ submission, onSubmit, onClose }: ResubmitDialogProps) {
const [proposedAlias, setProposedAlias] = useState<string>(submission.proposedAlias);
const [note, setNote] = useState<string>(submission.note ?? '');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitting) {
return;
}
setError(null);
setSubmitting(true);
try {
await onSubmit({
privateLinkId: submission.privateLinkId,
proposedAlias: proposedAlias.trim(),
note: note.trim() ? note.trim() : undefined,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to resubmit promotion');
} finally {
setSubmitting(false);
}
}
return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Resubmit promotion">
<form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header">
<h2>Edit &amp; resubmit</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
</div>
<p className="muted">
Resubmitting <span className="alias-pill">#{submission.privateLinkAlias ?? submission.privateLinkId}</span>
{submission.privateLinkTargetUrl ? <> <span className="truncate">{submission.privateLinkTargetUrl}</span></> : null}
</p>
{submission.rejectionReason ? (
<div className="resubmit-reason" role="note">
<strong>Requested changes:</strong>
<p>{submission.rejectionReason}</p>
</div>
) : null}
<div className="field">
<label htmlFor="resubmit-alias">Proposed public alias</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span>
<input
id="resubmit-alias"
name="proposedAlias"
value={proposedAlias}
onChange={(e) => setProposedAlias(e.target.value)}
required
maxLength={100}
autoComplete="off"
placeholder="public-alias"
disabled={submitting}
/>
</div>
<small className="hint alias-preview">
<span>The alias reviewers will publish this link under.</span>
{proposedAlias.trim() ? <span className="alias-pill">#{proposedAlias.trim()}</span> : null}
</small>
</div>
<div className="field">
<label htmlFor="resubmit-note">Note for reviewers (optional)</label>
<textarea
id="resubmit-note"
name="note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={1000}
placeholder="What did you change?"
disabled={submitting}
/>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting…' : 'Resubmit for review'}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
</div>
</form>
</div>
);
}
+104 -63
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { InboxIcon, PlusIcon } from 'lucide-react';
import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable';
import MySubmissionsList from '../components/MySubmissionsList';
import PromotionDialog from '../components/PromotionDialog';
import {
ApiClientError,
@@ -13,7 +15,10 @@ import {
updatePrivateLink,
} from '../lib/api';
type MyLinksView = 'links' | 'submissions';
export default function PrivateLinksPage() {
const [view, setView] = useState<MyLinksView>('links');
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -42,8 +47,10 @@ export default function PrivateLinksPage() {
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
if (view === 'links') {
void refresh();
}
}, [refresh, view]);
function toggleSelect(id: string) {
setSelected((prev) => {
@@ -117,76 +124,110 @@ export default function PrivateLinksPage() {
<section className="panel">
<header className="panel-header">
<div>
<h1>My Links</h1>
<p className="muted">Your personal shortlinks. Only visible to you.</p>
<h1>{view === 'links' ? 'My Links' : 'My Submissions'}</h1>
<p className="muted">
{view === 'links'
? 'Your personal shortlinks. Only visible to you.'
: 'Links you have submitted for public promotion.'}
</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>
{view === 'links' ? (
<>
<button
type="button"
className="action-btn action-btn--primary"
onClick={() => { setEditing(null); setShowCreate((v) => !v); }}
>
<PlusIcon aria-hidden="true" size={16} />
<span>{showCreate ? 'Close form' : 'New link'}</span>
</button>
<button
type="button"
className="action-btn action-btn--secondary"
onClick={() => setView('submissions')}
>
<InboxIcon aria-hidden="true" size={16} />
<span>My Submissions</span>
</button>
</>
) : (
<button
type="button"
className="link-action"
onClick={() => setView('links')}
>
Back to My Links
</button>
)}
</div>
</header>
{selected.size > 0 ? (
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<button
type="button"
className="link-action link-action--approve"
onClick={() => { setPromoteError(null); setShowPromote(true); }}
>
Promote to public
</button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div>
) : null}
{view === 'links' ? (
<>
{selected.size > 0 ? (
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<button
type="button"
className="link-action link-action--approve"
onClick={() => { setPromoteError(null); setShowPromote(true); }}
>
Promote to public
</button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div>
) : null}
{showPromote ? (
<PromotionDialog
links={selectedLinks}
onSubmit={handlePromote}
onClose={() => setShowPromote(false)}
/>
) : null}
{showPromote ? (
<PromotionDialog
links={selectedLinks}
onSubmit={handlePromote}
onClose={() => setShowPromote(false)}
/>
) : null}
{promoteError ? <p className="form-error" role="alert">{promoteError}</p> : null}
{promoteError ? <p className="form-error" role="alert">{promoteError}</p> : null}
{showCreate ? (
<div className="form-card">
<h2>Create link</h2>
<LinkForm
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
submitLabel="Create"
{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}
/>
</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}
/>
</>
) : (
<MySubmissionsList onSubmitted={() => void refresh()} />
)}
</section>
);
}
+45
View File
@@ -557,6 +557,40 @@ button:disabled { background: #C7D2FE; cursor: not-allowed; color: #818CF8; }
background: var(--background);
}
.action-btn {
align-items: center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s, transform 0.05s;
line-height: 1.4;
white-space: nowrap;
}
.action-btn:active { transform: translateY(1px); }
.action-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.action-btn--primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.action-btn--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: white; }
.action-btn--primary:disabled { background: #C7D2FE; border-color: #C7D2FE; cursor: not-allowed; color: white; }
.action-btn--secondary {
background: var(--success-soft);
border-color: #6EE7B7;
color: #065F46;
}
.action-btn--secondary:hover { background: #A7F3D0; border-color: var(--success); color: #065F46; }
.action-btn--secondary:disabled { opacity: 0.6; cursor: not-allowed; }
button.link-action {
background: var(--surface);
border: 1px solid var(--border-strong);
@@ -1141,6 +1175,17 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
.review-table .review-reason { color: var(--danger); }
.resubmit-reason {
background: var(--warning-soft);
border: 1px solid #FDE68A;
border-radius: var(--radius-sm);
color: #92400E;
font-size: 0.875rem;
padding: 0.65rem 0.85rem;
}
.resubmit-reason p { margin: 0.25rem 0 0; }
.resubmit-reason strong { font-weight: 700; }
.status-badge {
border-radius: 999px;
display: inline-flex;