754 lines
30 KiB
React
754 lines
30 KiB
React
// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm).
|
||
|
||
const { useState: useStateD, useEffect: useEffectD, useRef: useRefD, useCallback: useCallbackD } = React;
|
||
|
||
// ---- Rich Text Editor (lightweight WYSIWYG using contenteditable) ----
|
||
function RichTextEditor({ value, onChange, placeholder = "Add details...", collapsed = false, onExpand }) {
|
||
const editorRef = useRefD(null);
|
||
const [isExpanded, setIsExpanded] = useStateD(!collapsed || !!value);
|
||
const [showCopyMenu, setShowCopyMenu] = useStateD(false);
|
||
const [copyMenuPos, setCopyMenuPos] = useStateD({ x: 0, y: 0 });
|
||
const toolbarRef = useRefD(null);
|
||
const isEditingRef = useRefD(false);
|
||
const prevValueRef = useRefD(value);
|
||
|
||
// Initialize editor content only when value changes externally (not from our own edits)
|
||
useEffectD(() => {
|
||
if (editorRef.current && !isEditingRef.current) {
|
||
// Update if value changed from previous or editor is empty
|
||
if (prevValueRef.current !== value || editorRef.current.innerHTML === '') {
|
||
const html = MarkdownUtils.toHtml(value || '');
|
||
editorRef.current.innerHTML = html;
|
||
prevValueRef.current = value;
|
||
}
|
||
}
|
||
}, [value]);
|
||
|
||
const handleInput = useCallbackD(() => {
|
||
if (editorRef.current) {
|
||
isEditingRef.current = true;
|
||
const md = MarkdownUtils.toMarkdown(editorRef.current.innerHTML);
|
||
onChange?.(md || null);
|
||
// Reset editing flag after a short delay to allow state updates
|
||
setTimeout(() => { isEditingRef.current = false; }, 100);
|
||
}
|
||
}, [onChange]);
|
||
|
||
const execCmd = useCallbackD((cmd, val = null) => {
|
||
editorRef.current?.focus();
|
||
document.execCommand(cmd, false, val);
|
||
handleInput();
|
||
}, [handleInput]);
|
||
|
||
const handleKeyDown = useCallbackD((e) => {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
execCmd('insertText', ' ');
|
||
}
|
||
// Auto-convert "- " or "* " at line start to bullet list
|
||
if (e.key === ' ') {
|
||
const sel = window.getSelection();
|
||
if (sel.rangeCount > 0) {
|
||
const range = sel.getRangeAt(0);
|
||
const node = range.startContainer;
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
const text = node.textContent;
|
||
const offset = range.startOffset;
|
||
// Check if cursor is right after "- " or "* " at line start
|
||
if (offset === 1 && (text[0] === '-' || text[0] === '*')) {
|
||
e.preventDefault();
|
||
// Remove the "-" or "*" character
|
||
node.textContent = text.slice(1);
|
||
// Convert to bullet list
|
||
execCmd('insertUnorderedList');
|
||
return;
|
||
}
|
||
// Also check for "1. " to convert to numbered list
|
||
if (offset === 2 && text[0] === '1' && text[1] === '.') {
|
||
e.preventDefault();
|
||
node.textContent = text.slice(2);
|
||
execCmd('insertOrderedList');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Ctrl/Cmd + B/I/K shortcuts
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (e.key === 'b') { e.preventDefault(); execCmd('bold'); }
|
||
if (e.key === 'i') { e.preventDefault(); execCmd('italic'); }
|
||
if (e.key === 'k') { e.preventDefault(); insertLink(); }
|
||
}
|
||
}, [execCmd]);
|
||
|
||
const insertLink = useCallbackD(() => {
|
||
const url = prompt('Enter URL:');
|
||
if (url) execCmd('createLink', url);
|
||
}, [execCmd]);
|
||
|
||
const insertImage = useCallbackD(() => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
execCmd('insertHTML', `<img src="${reader.result}" alt="${file.name}" class="rte-img">`);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
input.click();
|
||
}, [execCmd]);
|
||
|
||
const insertTable = useCallbackD(() => {
|
||
const html = '<table class="rte-table"><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead><tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table><p><br></p>';
|
||
execCmd('insertHTML', html);
|
||
}, [execCmd]);
|
||
|
||
const insertCodeBlock = useCallbackD(() => {
|
||
execCmd('insertHTML', '<pre class="rte-code-block"><code>code here</code></pre><p><br></p>');
|
||
}, [execCmd]);
|
||
|
||
const handleCopyMenu = useCallbackD((e) => {
|
||
e.preventDefault();
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
setCopyMenuPos({ x: rect.left, y: rect.bottom + 4 });
|
||
setShowCopyMenu(true);
|
||
}, []);
|
||
|
||
const copyAs = useCallbackD(async (format) => {
|
||
setShowCopyMenu(false);
|
||
const html = editorRef.current?.innerHTML || '';
|
||
const md = MarkdownUtils.toMarkdown(html);
|
||
if (format === 'html') await ClipboardUtils.copyHtml(html, md);
|
||
else if (format === 'markdown') await ClipboardUtils.copyMarkdown(md);
|
||
else if (format === 'text') await ClipboardUtils.copyPlainText(html);
|
||
}, []);
|
||
|
||
const expand = useCallbackD(() => {
|
||
setIsExpanded(true);
|
||
onExpand?.();
|
||
setTimeout(() => editorRef.current?.focus(), 50);
|
||
}, [onExpand]);
|
||
|
||
// Close copy menu on outside click
|
||
useEffectD(() => {
|
||
if (!showCopyMenu) return;
|
||
const close = () => setShowCopyMenu(false);
|
||
document.addEventListener('click', close);
|
||
return () => document.removeEventListener('click', close);
|
||
}, [showCopyMenu]);
|
||
|
||
if (!isExpanded) {
|
||
return (
|
||
<div className="rte-collapsed" onClick={expand}>
|
||
<span className="rte-collapsed-placeholder">{placeholder}</span>
|
||
<Icon name="plus" size={12} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rte-wrap">
|
||
<div className="rte-toolbar" ref={toolbarRef}>
|
||
<button type="button" onClick={() => execCmd('bold')} title="Bold (Ctrl+B)"><strong>B</strong></button>
|
||
<button type="button" onClick={() => execCmd('italic')} title="Italic (Ctrl+I)"><em>I</em></button>
|
||
<span className="rte-toolbar-sep" />
|
||
<button type="button" onClick={insertLink} title="Link (Ctrl+K)">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><path d="M8 12l4-4M6 10l-1 1a3 3 0 0 0 4.24 4.24l1-1M14 10l1-1a3 3 0 0 0-4.24-4.24l-1 1" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||
</button>
|
||
<span className="rte-toolbar-sep" />
|
||
<button type="button" onClick={() => execCmd('formatBlock', 'h1')} title="Heading 1">H1</button>
|
||
<button type="button" onClick={() => execCmd('formatBlock', 'h2')} title="Heading 2">H2</button>
|
||
<button type="button" onClick={() => execCmd('formatBlock', 'h3')} title="Heading 3">H3</button>
|
||
<span className="rte-toolbar-sep" />
|
||
<button type="button" onClick={() => execCmd('insertUnorderedList')} title="Bullet list">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="8" y1="6" x2="17" y2="6"/><line x1="8" y1="10" x2="17" y2="10"/><line x1="8" y1="14" x2="17" y2="14"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="10" r="1" fill="currentColor"/><circle cx="4" cy="14" r="1" fill="currentColor"/></g></svg>
|
||
</button>
|
||
<button type="button" onClick={() => execCmd('insertOrderedList')} title="Numbered list">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="8" y1="6" x2="17" y2="6"/><line x1="8" y1="10" x2="17" y2="10"/><line x1="8" y1="14" x2="17" y2="14"/><text x="3" y="7" fontSize="5" fill="currentColor" stroke="none">1</text><text x="3" y="11" fontSize="5" fill="currentColor" stroke="none">2</text><text x="3" y="15" fontSize="5" fill="currentColor" stroke="none">3</text></g></svg>
|
||
</button>
|
||
<span className="rte-toolbar-sep" />
|
||
<button type="button" onClick={insertCodeBlock} title="Code block">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="7,5 3,10 7,15"/><polyline points="13,5 17,10 13,15"/></g></svg>
|
||
</button>
|
||
<button type="button" onClick={insertTable} title="Table">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="4" width="14" height="12" rx="1.5"/><line x1="3" y1="8" x2="17" y2="8"/><line x1="10" y1="8" x2="10" y2="16"/></g></svg>
|
||
</button>
|
||
<button type="button" onClick={insertImage} title="Image">
|
||
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="4" width="14" height="12" rx="1.5"/><circle cx="7" cy="8" r="1.5"/><path d="M3 14l4-4 3 3 4-4 3 3"/></g></svg>
|
||
</button>
|
||
<span className="rte-toolbar-sep" />
|
||
<button type="button" onClick={handleCopyMenu} title="Copy as..." className="rte-copy-btn">
|
||
<Icon name="copy" size={12} />
|
||
<svg width="8" height="8" viewBox="0 0 10 10"><polyline points="2,4 5,7 8,4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</button>
|
||
</div>
|
||
{showCopyMenu && (
|
||
<div className="rte-copy-menu" style={{ left: copyMenuPos.x, top: copyMenuPos.y }}>
|
||
<button type="button" onClick={() => copyAs('html')}>Copy for Docs/Word</button>
|
||
<button type="button" onClick={() => copyAs('markdown')}>Copy as Markdown</button>
|
||
<button type="button" onClick={() => copyAs('text')}>Copy as Plain Text</button>
|
||
</div>
|
||
)}
|
||
<div
|
||
ref={editorRef}
|
||
className="rte-editor"
|
||
contentEditable
|
||
onInput={handleInput}
|
||
onKeyDown={handleKeyDown}
|
||
data-placeholder={placeholder}
|
||
onContextMenu={(e) => {
|
||
// Allow default context menu but add copy options via showCopyMenu
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Dialog({ open, onClose, children, width = 460, labelledBy }) {
|
||
useEffectD(() => {
|
||
if (!open) return;
|
||
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
||
document.addEventListener("keydown", onKey);
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.removeEventListener("keydown", onKey);
|
||
document.body.style.overflow = "";
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
if (!open) return null;
|
||
return (
|
||
<div className="dlg-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
|
||
<div className="dlg" role="dialog" aria-modal="true" aria-labelledby={labelledBy} style={{ maxWidth: width }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DialogHeader({ eyebrow, title, onClose, id }) {
|
||
return (
|
||
<div className="dlg-head">
|
||
<div>
|
||
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
|
||
<h3 id={id} className="dlg-title">{title}</h3>
|
||
</div>
|
||
{onClose && (
|
||
<button className="dlg-close" onClick={onClose} aria-label="Close">
|
||
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Item editor (used for both edit and add) ---
|
||
function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [], isMultiProject = false, projects = [], defaultProjectId = null }) {
|
||
const [title, setTitle] = useStateD("");
|
||
const [body, setBody] = useStateD(null);
|
||
const [priority, setPriority] = useStateD("P2");
|
||
const [status, setStatus] = useStateD("open");
|
||
const [due, setDue] = useStateD("");
|
||
const [tags, setTags] = useStateD([]);
|
||
const [tagInput, setTagInput] = useStateD("");
|
||
const [reason, setReason] = useStateD("");
|
||
const [progress, setProgress] = useStateD(0);
|
||
const [createAsProject, setCreateAsProject] = useStateD(false);
|
||
const [projectPath, setProjectPath] = useStateD("");
|
||
const [selectedProjectId, setSelectedProjectId] = useStateD(null);
|
||
const titleRef = useRefD(null);
|
||
|
||
const showProjectOption = isMultiProject && mode === "add";
|
||
const showPathInput = showProjectOption && createAsProject;
|
||
const showProjectSelect = showProjectOption && !createAsProject && projects.length > 0;
|
||
|
||
useEffectD(() => {
|
||
if (!open) return;
|
||
setTitle(initial?.title || "");
|
||
setBody(initial?.body || null);
|
||
setPriority(initial?.priority || "P2");
|
||
setStatus(initial?.status || "open");
|
||
setDue(initial?.due || "");
|
||
setTags(initial?.tags || []);
|
||
setTagInput("");
|
||
setReason(initial?.reason || "");
|
||
setProgress(initial?.progress ?? 0);
|
||
setCreateAsProject(false);
|
||
setProjectPath("");
|
||
setSelectedProjectId(
|
||
defaultProjectId && projects.some(p => p.id === defaultProjectId)
|
||
? defaultProjectId
|
||
: (projects[0] ? projects[0].id : null)
|
||
);
|
||
setTimeout(() => titleRef.current?.focus(), 50);
|
||
}, [open, initial]);
|
||
|
||
const addTag = (t) => {
|
||
const v = t.trim().replace(/^#/, "");
|
||
if (!v) return;
|
||
if (tags.includes(v)) return;
|
||
setTags([...tags, v]);
|
||
};
|
||
const removeTag = (t) => setTags(tags.filter(x => x !== t));
|
||
|
||
const submit = (e) => {
|
||
e?.preventDefault();
|
||
// Item type requires a title; Project type only requires a path (title is an optional display name).
|
||
if (showPathInput ? !projectPath.trim() : !title.trim()) return;
|
||
// Reconcile progress with status before submitting.
|
||
let outProgress = snapProgress(progress);
|
||
if (status === "done") outProgress = 100;
|
||
const payload = {
|
||
title: title.trim(),
|
||
body: body || null,
|
||
priority, status,
|
||
due: due || null,
|
||
tags,
|
||
reason: status === "blocked" ? reason.trim() || null : null,
|
||
progress: outProgress
|
||
};
|
||
if (isMultiProject) {
|
||
const rawPath = projectPath.trim();
|
||
payload.createAsProject = showPathInput;
|
||
payload.projectPath = showPathInput
|
||
? (rawPath.endsWith('.md') ? rawPath : rawPath.replace(/\/$/, '') + '/backlog.md')
|
||
: null;
|
||
payload.projectId = showProjectSelect ? selectedProjectId : null;
|
||
}
|
||
onSubmit(payload);
|
||
};
|
||
|
||
const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8);
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onClose} width={520} labelledBy="dlg-item-title">
|
||
<form onSubmit={submit} onKeyDown={(e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
|
||
}}>
|
||
<DialogHeader
|
||
id="dlg-item-title"
|
||
eyebrow={mode === "view" ? "Archived item" : mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
|
||
title={mode === "view" ? "View details (read-only)" : mode === "edit" ? "Edit details" : "Create item"}
|
||
onClose={onClose}
|
||
/>
|
||
|
||
<div className="dlg-body">
|
||
<div className="field">
|
||
<label className="field-label">Title</label>
|
||
<input
|
||
ref={titleRef}
|
||
className="text-input"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder={showPathInput ? "Optional display name" : "What needs to happen?"}
|
||
required={!showPathInput}
|
||
readOnly={mode === "view"}
|
||
/>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="field-label">Details <span className="field-hint">{mode === "view" ? "read-only" : "rich text, supports markdown"}</span></label>
|
||
{mode === "view" ? (
|
||
<div className="rte-readonly" dangerouslySetInnerHTML={{ __html: MarkdownUtils.toHtml(body || '') }} />
|
||
) : (
|
||
<RichTextEditor
|
||
value={body}
|
||
onChange={setBody}
|
||
placeholder="Add notes, context, or details..."
|
||
collapsed={!body}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="field-row">
|
||
<div className="field">
|
||
<label className="field-label">Priority</label>
|
||
<div className="seg">
|
||
{PRIORITIES.map(p => (
|
||
<button type="button" key={p}
|
||
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
|
||
onClick={() => mode !== "view" && setPriority(p)}
|
||
disabled={mode === "view"}>
|
||
{p === "P0" && <span className="p0-dot"/>}
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Due</label>
|
||
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)} readOnly={mode === "view"}/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="field-label">Status</label>
|
||
<div className="status-grid">
|
||
{STATUSES.map(s => (
|
||
<button type="button" key={s.key}
|
||
className={`status-card ${status === s.key ? "active" : ""}`}
|
||
onClick={() => mode !== "view" && setStatus(s.key)}
|
||
disabled={mode === "view"}>
|
||
<span className={`status-card-icon status-${s.key}`}>
|
||
<StatusIcon status={s.key} size={16}/>
|
||
</span>
|
||
<span className="status-card-label">{s.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{status !== "cancelled" && (
|
||
<div className="field">
|
||
<label className="field-label">
|
||
Completion
|
||
<span className="field-hint">
|
||
{mode === "view" ? "read-only" : status === "done"
|
||
? "locked at 100% while marked done"
|
||
: "rough estimate — set 100% to mark done"}
|
||
</span>
|
||
</label>
|
||
<div className="progress-row">
|
||
<div className={`seg progress-seg ${status === "done" || mode === "view" ? "locked" : ""}`}>
|
||
{PROGRESS_STEPS.map(p => (
|
||
<button type="button" key={p}
|
||
className={`seg-btn ${progress === p ? "active" : ""}`}
|
||
onClick={() => setProgress(p)}
|
||
disabled={status === "done" || mode === "view"}>
|
||
{p}%
|
||
</button>
|
||
))}
|
||
</div>
|
||
<ProgressGauge value={status === "done" ? 100 : progress} size="md"/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status === "blocked" && (
|
||
<div className="field">
|
||
<label className="field-label">Block reason</label>
|
||
<input className="text-input" value={reason}
|
||
onChange={(e) => setReason(e.target.value)}
|
||
placeholder="Waiting for…"
|
||
readOnly={mode === "view"}/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="field">
|
||
<label className="field-label">Tags {mode !== "view" && <span className="field-hint">type a name, press Enter or comma to add</span>}</label>
|
||
{mode === "view" ? (
|
||
<div className="tag-list-readonly">
|
||
{tags.length === 0 ? <span className="muted">No tags</span> : tags.map(t => (
|
||
<span key={t} className="tag">{t}</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<TagAutocompleteInput
|
||
tags={tags}
|
||
setTags={setTags}
|
||
tagInput={tagInput}
|
||
setTagInput={setTagInput}
|
||
addTag={addTag}
|
||
removeTag={removeTag}
|
||
allTags={recentTags}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{showProjectOption && (
|
||
<div className="field">
|
||
<label className="field-label">Type</label>
|
||
<div className="status-grid" style={{gridTemplateColumns: '1fr 1fr'}}>
|
||
<button type="button"
|
||
className={`status-card ${!createAsProject ? 'active' : ''}`}
|
||
onClick={() => setCreateAsProject(false)}>
|
||
<span className="status-card-icon" style={{background: 'var(--bg-2)'}}>
|
||
<Icon name="check" size={16}/>
|
||
</span>
|
||
<span className="status-card-label">Item</span>
|
||
</button>
|
||
<button type="button"
|
||
className={`status-card ${createAsProject ? 'active' : ''}`}
|
||
onClick={() => setCreateAsProject(true)}>
|
||
<span className="status-card-icon" style={{background: 'var(--bg-2)'}}>
|
||
<Icon name="folder" size={16}/>
|
||
</span>
|
||
<span className="status-card-label">Project</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showProjectSelect && (
|
||
<div className="field">
|
||
<label className="field-label">Project <span className="field-hint">where this item will live</span></label>
|
||
<select
|
||
className="text-input"
|
||
value={selectedProjectId ?? ""}
|
||
onChange={(e) => setSelectedProjectId(e.target.value)}>
|
||
{projects.map(p => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{showPathInput && (
|
||
<div className="field">
|
||
<label className="field-label">File path <span className="field-hint">created if missing; backlog.md appended for directories</span></label>
|
||
<input
|
||
className="text-input"
|
||
value={projectPath}
|
||
onChange={(e) => setProjectPath(e.target.value)}
|
||
placeholder="/home/user/project/backlog.md"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="dlg-foot">
|
||
{mode === "view" ? (
|
||
<>
|
||
<span className="dlg-hint">Read-only · archived {initial?.archived || ''}</span>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn-primary" disabled={showPathInput ? !projectPath.trim() : !title.trim()}>
|
||
{mode === "edit" ? "Save changes" : (showPathInput ? "Register project" : "Create item")}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// --- Confirm dialog ---
|
||
function ConfirmDialog({ open, title, message, detail, checkbox, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
|
||
const [checked, setChecked] = useStateD(false);
|
||
|
||
useEffectD(() => {
|
||
if (open) setChecked(false);
|
||
}, [open]);
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onCancel} width={420} labelledBy="dlg-confirm-title">
|
||
<DialogHeader id="dlg-confirm-title" eyebrow="Confirm" title={title} onClose={onCancel}/>
|
||
<div className="dlg-body">
|
||
<p className="dlg-msg">{message}</p>
|
||
{detail && <div className="dlg-detail">{detail}</div>}
|
||
{checkbox && (
|
||
<label className="dlg-checkbox">
|
||
<input type="checkbox" checked={checked} onChange={e => setChecked(e.target.checked)}/>
|
||
<span>{checkbox}</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="dlg-foot">
|
||
<span/>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
|
||
<button type="button"
|
||
className={danger ? "btn-danger" : "btn-primary"}
|
||
onClick={() => onConfirm(checked)} autoFocus>{confirmLabel}</button>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// --- Prompt dialog (styled input) ---
|
||
function PromptDialog({ open, title, message, placeholder = "", confirmLabel = "OK", onCancel, onConfirm }) {
|
||
const [value, setValue] = useStateD("");
|
||
const inputRef = useRefD(null);
|
||
|
||
useEffectD(() => {
|
||
if (open) {
|
||
setValue("");
|
||
setTimeout(() => inputRef.current?.focus(), 50);
|
||
}
|
||
}, [open]);
|
||
|
||
const submit = (e) => {
|
||
e?.preventDefault();
|
||
if (!value.trim()) return;
|
||
onConfirm(value.trim());
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onCancel} width={480} labelledBy="dlg-prompt-title">
|
||
<form onSubmit={submit}>
|
||
<DialogHeader id="dlg-prompt-title" eyebrow="Input" title={title} onClose={onCancel}/>
|
||
<div className="dlg-body">
|
||
<p className="dlg-msg">{message}</p>
|
||
<input
|
||
ref={inputRef}
|
||
className="text-input"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
placeholder={placeholder}
|
||
style={{marginTop: 12}}
|
||
/>
|
||
</div>
|
||
<div className="dlg-foot">
|
||
<span/>
|
||
<div className="dlg-foot-actions">
|
||
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
|
||
<button type="submit" className="btn-primary" disabled={!value.trim()}>{confirmLabel}</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// --- Tag autocomplete (typed input + filtered dropdown) ---
|
||
function TagAutocompleteInput({ tags, setTags, tagInput, setTagInput, addTag, removeTag, allTags }) {
|
||
const [focused, setFocused] = useStateD(false);
|
||
const [highlighted, setHighlighted] = useStateD(0);
|
||
const inputRef = useRefD(null);
|
||
const wrapRef = useRefD(null);
|
||
|
||
const q = tagInput.trim().toLowerCase().replace(/^#/, "");
|
||
// suggestions: filter out already-applied; rank exact prefix > contains
|
||
const pool = (allTags || []).filter(t => !tags.includes(t));
|
||
let suggestions;
|
||
if (q) {
|
||
const starts = pool.filter(t => t.toLowerCase().startsWith(q));
|
||
const contains = pool.filter(t => !t.toLowerCase().startsWith(q) && t.toLowerCase().includes(q));
|
||
suggestions = [...starts, ...contains].slice(0, 8);
|
||
} else {
|
||
suggestions = pool.slice(0, 8); // recents
|
||
}
|
||
const exactMatch = q && pool.some(t => t.toLowerCase() === q);
|
||
const showCreate = q && !exactMatch && !tags.includes(q);
|
||
// index 0 = "create new" (if shown), then suggestions
|
||
const items = [
|
||
...(showCreate ? [{ kind: "create", value: q }] : []),
|
||
...suggestions.map(t => ({ kind: "suggestion", value: t }))
|
||
];
|
||
|
||
useEffectD(() => { setHighlighted(0); }, [tagInput, focused]);
|
||
|
||
const pick = (it) => {
|
||
if (!it) return;
|
||
addTag(it.value);
|
||
setTagInput("");
|
||
setHighlighted(0);
|
||
};
|
||
|
||
const onKey = (e) => {
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
if (items.length) setHighlighted((h) => (h + 1) % items.length);
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (items.length) setHighlighted((h) => (h - 1 + items.length) % items.length);
|
||
} else if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (items[highlighted]) pick(items[highlighted]);
|
||
else if (tagInput.trim()) { addTag(tagInput); setTagInput(""); }
|
||
} else if (e.key === "," || e.key === "Tab") {
|
||
if (tagInput.trim()) {
|
||
e.preventDefault();
|
||
if (items[highlighted]) pick(items[highlighted]);
|
||
else { addTag(tagInput); setTagInput(""); }
|
||
}
|
||
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
||
removeTag(tags[tags.length - 1]);
|
||
} else if (e.key === "Escape") {
|
||
if (tagInput) { setTagInput(""); e.preventDefault(); }
|
||
else inputRef.current?.blur();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="tag-ac" ref={wrapRef}>
|
||
<div className={`tag-input ${focused ? "focused" : ""}`} onClick={(e) => {
|
||
if (e.target === e.currentTarget) inputRef.current?.focus();
|
||
}}>
|
||
{tags.map(t => (
|
||
<span key={t} className="tag-pill">
|
||
#{t}
|
||
<button type="button" onClick={() => removeTag(t)} aria-label={`Remove ${t}`}>×</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
ref={inputRef}
|
||
className="tag-input-field"
|
||
placeholder={tags.length ? "+ add tag" : "e.g. urgent, frontend, errand"}
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onFocus={() => setFocused(true)}
|
||
onBlur={() => setTimeout(() => setFocused(false), 150)}
|
||
onKeyDown={onKey}
|
||
autoComplete="off"
|
||
aria-autocomplete="list"
|
||
aria-expanded={focused && items.length > 0}
|
||
/>
|
||
</div>
|
||
|
||
{focused && items.length > 0 && (
|
||
<div className="tag-ac-pop" role="listbox">
|
||
{!q && (
|
||
<div className="tag-ac-cap">Recent</div>
|
||
)}
|
||
{items.map((it, i) => (
|
||
<button
|
||
key={`${it.kind}-${it.value}`}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={i === highlighted}
|
||
className={`tag-ac-item ${i === highlighted ? "highlighted" : ""} ${it.kind === "create" ? "create" : ""}`}
|
||
onMouseEnter={() => setHighlighted(i)}
|
||
onMouseDown={(e) => { e.preventDefault(); pick(it); }}
|
||
>
|
||
{it.kind === "create" ? (
|
||
<>
|
||
<Icon name="plus" size={11}/>
|
||
<span>Create <strong>#{it.value}</strong></span>
|
||
</>
|
||
) : (
|
||
<span>{q ? <TagMatchHL text={it.value} match={q}/> : `#${it.value}`}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
<div className="tag-ac-foot">
|
||
<span><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>
|
||
<span><kbd>↵</kbd> to add</span>
|
||
<span><kbd>esc</kbd> to dismiss</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TagMatchHL({ text, match }) {
|
||
const i = text.toLowerCase().indexOf(match);
|
||
if (i < 0) return `#${text}`;
|
||
return (
|
||
<>
|
||
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
window.Dialog = Dialog;
|
||
window.ItemDialog = ItemDialog;
|
||
window.ConfirmDialog = ConfirmDialog;
|
||
window.PromptDialog = PromptDialog;
|
||
window.RichTextEditor = RichTextEditor;
|