Update UI

This commit is contained in:
2026-06-20 21:43:16 +10:00
parent 425a91c347
commit 5b3472c2df
3 changed files with 298 additions and 6 deletions
+216
View File
@@ -0,0 +1,216 @@
import { useRef, useState, type KeyboardEvent } from 'react';
import {
BoldIcon,
CodeIcon,
EyeIcon,
HeadingIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
PencilIcon,
QuoteIcon,
StrikethroughIcon,
} from 'lucide-react';
import MarkdownContent from './MarkdownContent';
interface MarkdownEditorProps {
readonly value: string;
readonly onChange: (value: string) => void;
readonly placeholder?: string;
readonly rows?: number;
readonly disabled?: boolean;
}
type WrapAction = 'bold' | 'italic' | 'strike' | 'code' | 'link';
type LineAction = 'h1' | 'h2' | 'h3' | 'ul' | 'ol' | 'quote';
const WRAP_SYNTAX: Record<WrapAction, { prefix: string; suffix: string; placeholder: string }> = {
bold: { prefix: '**', suffix: '**', placeholder: 'bold text' },
italic: { prefix: '*', suffix: '*', placeholder: 'italic text' },
strike: { prefix: '~~', suffix: '~~', placeholder: 'strikethrough' },
code: { prefix: '`', suffix: '`', placeholder: 'code' },
link: { prefix: '[', suffix: '](https://)', placeholder: 'link text' },
};
const LINE_PREFIX: Record<LineAction, string> = {
h1: '# ',
h2: '## ',
h3: '### ',
ul: '- ',
ol: '1. ',
quote: '> ',
};
export default function MarkdownEditor({
value,
onChange,
placeholder = '# Hello world',
rows = 12,
disabled = false,
}: MarkdownEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [mode, setMode] = useState<'write' | 'preview'>('write');
function applyWrap(action: WrapAction) {
const textarea = textareaRef.current;
if (!textarea || disabled) return;
const { prefix, suffix, placeholder: ph } = WRAP_SYNTAX[action];
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end);
const inner = selected || ph;
const next = value.slice(0, start) + prefix + inner + suffix + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
textarea.focus();
const selStart = start + prefix.length;
const selEnd = selStart + inner.length;
textarea.setSelectionRange(selStart, selEnd);
});
}
function applyLine(action: LineAction) {
const textarea = textareaRef.current;
if (!textarea || disabled) return;
const prefix = LINE_PREFIX[action];
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', end);
const actualLineEnd = lineEnd === -1 ? value.length : lineEnd;
const lineBlock = value.slice(lineStart, actualLineEnd);
const lines = lineBlock.split('\n');
const isFirst = (i: number) => action === 'ol' && i > 0;
const transformed = lines
.map((line, i) => (isFirst(i) ? `${i + 1}. ` : prefix) + line)
.join('\n');
const next = value.slice(0, lineStart) + transformed + value.slice(actualLineEnd);
onChange(next);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(lineStart, lineStart + transformed.length);
});
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (disabled) return;
const mod = event.metaKey || event.ctrlKey;
if (!mod) return;
const key = event.key.toLowerCase();
const map: Record<string, () => void> = {
b: () => applyWrap('bold'),
i: () => applyWrap('italic'),
k: () => applyWrap('code'),
};
if (map[key]) {
event.preventDefault();
map[key]();
}
}
return (
<div className="md-editor">
<div className="md-editor__toolbar" role="toolbar" aria-label="Markdown formatting">
<div className="md-editor__group">
<ToolbarButton title="Heading 1 (Ctrl/Cmd+Alt+1)" disabled={disabled} onClick={() => applyLine('h1')}>
<HeadingIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Bold (Ctrl/Cmd+B)" disabled={disabled} onClick={() => applyWrap('bold')}>
<BoldIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Italic (Ctrl/Cmd+I)" disabled={disabled} onClick={() => applyWrap('italic')}>
<ItalicIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Strikethrough" disabled={disabled} onClick={() => applyWrap('strike')}>
<StrikethroughIcon aria-hidden="true" size={15} />
</ToolbarButton>
</div>
<div className="md-editor__divider" />
<div className="md-editor__group">
<ToolbarButton title="Link" disabled={disabled} onClick={() => applyWrap('link')}>
<LinkIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Inline code (Ctrl/Cmd+K)" disabled={disabled} onClick={() => applyWrap('code')}>
<CodeIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Bulleted list" disabled={disabled} onClick={() => applyLine('ul')}>
<ListIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Numbered list" disabled={disabled} onClick={() => applyLine('ol')}>
<ListOrderedIcon aria-hidden="true" size={15} />
</ToolbarButton>
<ToolbarButton title="Quote" disabled={disabled} onClick={() => applyLine('quote')}>
<QuoteIcon aria-hidden="true" size={15} />
</ToolbarButton>
</div>
<div className="md-editor__spacer" />
<div className="md-editor__mode-toggle">
<ToolbarButton title="Write" disabled={disabled} active={mode === 'write'} onClick={() => setMode('write')}>
<PencilIcon aria-hidden="true" size={14} />
<span>Write</span>
</ToolbarButton>
<ToolbarButton title="Preview" disabled={disabled} active={mode === 'preview'} onClick={() => setMode('preview')}>
<EyeIcon aria-hidden="true" size={14} />
<span>Preview</span>
</ToolbarButton>
</div>
</div>
{mode === 'write' ? (
<textarea
ref={textareaRef}
className="md-editor__textarea"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
disabled={disabled}
spellCheck
/>
) : (
<div className="md-editor__preview">
{value.trim() ? (
<MarkdownContent markdown={value} />
) : (
<p className="muted">Nothing to preview yet.</p>
)}
</div>
)}
</div>
);
}
function ToolbarButton({
title,
onClick,
disabled,
active,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`md-editor__btn${active ? ' is-active' : ''}`}
title={title}
aria-label={title}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
+4 -6
View File
@@ -4,6 +4,7 @@ import { Chart, registerables } from 'chart.js';
import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from 'lucide-react';
import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog';
import MarkdownContent from '../components/MarkdownContent';
import MarkdownEditor from '../components/MarkdownEditor';
import PromotionDialog from '../components/PromotionDialog';
import {
ApiClientError,
@@ -529,7 +530,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{copyError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{copyError}</p> : null}
{editError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{editError}</p> : null}
{editing && editInput ? (
<div className="form-card detail-edit-form">
<div className="form-card detail-edit-form link-form">
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem' }}>Edit link</h2>
<div className="field">
<label>Alias</label>
@@ -564,12 +565,9 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
) : (
<div className="field">
<label htmlFor="edit-content">Markdown content</label>
<textarea
id="edit-content"
<MarkdownEditor
value={editInput.contentMarkdown ?? ''}
onChange={(e) => updateEdit('contentMarkdown', e.target.value)}
required
rows={10}
onChange={(v) => updateEdit('contentMarkdown', v)}
placeholder="# Hello world"
disabled={saving}
/>
+78
View File
@@ -1667,6 +1667,84 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
background: var(--background); color: var(--muted); cursor: not-allowed;
}
/* Markdown editor */
.md-editor {
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
}
.md-editor:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.md-editor__toolbar {
align-items: center;
background: var(--background);
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
padding: 0.35rem 0.5rem;
}
.md-editor__group { display: flex; gap: 0.1rem; }
.md-editor__divider {
width: 1px;
height: 1.1rem;
background: var(--border-strong);
margin: 0 0.3rem;
}
.md-editor__spacer { flex: 1; }
.md-editor__mode-toggle { display: flex; gap: 0.1rem; }
.md-editor__btn {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
color: var(--muted);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.75rem;
font-weight: 500;
gap: 0.3rem;
padding: 0.3rem 0.45rem;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.md-editor__btn:hover {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
.md-editor__btn.is-active {
background: var(--accent-soft);
border-color: rgba(99,102,241,0.2);
color: var(--accent-text);
}
.md-editor__btn:disabled { opacity: 0.4; cursor: not-allowed; }
.md-editor__textarea {
background: var(--surface);
border: none;
border-radius: 0;
color: var(--text);
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.875rem;
line-height: 1.6;
padding: 0.875rem 1rem;
width: 100%;
resize: vertical;
min-height: 200px;
outline: none;
}
.md-editor__preview {
background: var(--surface);
min-height: 200px;
max-height: 480px;
overflow-y: auto;
padding: 1rem 1.25rem;
}
.template-preview .template-params-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.6rem; margin: 0.75rem 0 1rem;