mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
Update UI
This commit is contained in:
@@ -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 +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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user