diff --git a/src/app.jsx b/src/app.jsx index b62b0473c..9482d020b 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -10,6 +10,7 @@ import 'autotrack'; // used by google-analytics in ../index.jade import '../styles/common/common.scss'; import '../styles/helvetica/app.scss'; import '../styles/helvetica/dark-theme.scss'; +import './components/docs/documents.scss'; import { createBrowserHistory } from 'history'; import configureStore from './redux/configure-store'; @@ -42,6 +43,8 @@ const Summary = lazyLoad(() => import('./components/summary')); const Groups = lazyLoad(() => import('./components/groups')); const BacklinksFeed = lazyLoad(() => import('./components/backlinks-feed')); const UserMedia = lazyLoad(() => import('./components/user-media')); +const DocumentsPage = lazyLoad(() => import('./components/docs/DocumentsPage')); +const PublicDocumentView = lazyLoad(() => import('./components/docs/PublicDocumentView')); Sentry.init({ dsn: CONFIG.sentry.publicDSN, @@ -326,6 +329,11 @@ function App() { component={lazyLoad(() => import('./components/archive-post'))} {...generateRouteHooks(boundRouteActions('archivePost'))} /> + + {/* Public document viewer — placed before 404 to prevent /docs/:slug matching /:userName */} + + diff --git a/src/components/docs/DocumentList.jsx b/src/components/docs/DocumentList.jsx new file mode 100644 index 000000000..06eba9b39 --- /dev/null +++ b/src/components/docs/DocumentList.jsx @@ -0,0 +1,140 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { Icon } from '../fontawesome-icons'; +import { + faFile, + faFolder, + faFolderOpen, + faPlus, + faTag, + faChevronRight, + faChevronDown, + faBars, +} from '@fortawesome/free-solid-svg-icons'; + +import { getDocumentTree } from '../../redux/action-creators'; +import { Throbber } from '../throbber'; + +function TreeItem({ node, depth, selectedId, onSelect }) { + const [expanded, setExpanded] = useState(depth < 1); + const hasChildren = node.children?.length > 0; + + useEffect(() => { + if (node.id === selectedId) { + setExpanded(true); + } + }, [node.id, selectedId]); + + return ( +
+ {hasChildren ? ( + + ) : ( + + )} + + {hasChildren && expanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export default function DocumentList({ onSelect, onCreate, selectedId, refreshKey }) { + const dispatch = useDispatch(); + const treeStatus = useSelector((state) => state.documentsTree); + const treeData = useSelector((state) => state.documentsTreeData); + const [showTree, setShowTree] = useState(false); + const panelRef = useRef(null); + + // Refresh tree whenever the Browse panel opens + useEffect(() => { + if (showTree) dispatch(getDocumentTree()); + }, [showTree, dispatch]); + + // Also refresh when refreshKey increments (mutation happened) + useEffect(() => { + dispatch(getDocumentTree()); + }, [refreshKey, dispatch]); + useEffect(() => { + if (!showTree) return; + const handleClick = (e) => { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setShowTree(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [showTree]); + + const tree = treeData?.tree || []; + + if (treeStatus.error) { + return
Failed to load documents
; + } + + return ( +
+
+ + {selectedId && ( + + {treeData && (() => { + const find = (nodes) => { + for (const n of nodes) { + if (n.id === selectedId) return n.title; + if (n.children) { + const found = find(n.children); + if (found) return found; + } + } + return null; + }; + return find(tree) || 'Document'; + })()} + + )} +
+ +
+
+ + {showTree && ( +
+ {treeStatus.loading ? ( +
+ ) : tree.length === 0 ? ( +
No documents yet
+ ) : ( +
+ {tree.map((node) => ( + { onSelect(id); setShowTree(false); }} /> + ))} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/docs/DocumentView.jsx b/src/components/docs/DocumentView.jsx new file mode 100644 index 000000000..7809282b9 --- /dev/null +++ b/src/components/docs/DocumentView.jsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Icon } from '../fontawesome-icons'; +import { faSave, faTrashAlt, faCode, faDownload, faUpload, faCopy, faLink } from '@fortawesome/free-solid-svg-icons'; +import { parseMarkdown } from '../../docs/lib/index'; +import { blockHtml } from '../../docs/lib/block-html'; +function formatDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return iso; + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export default function DocumentView({ document: doc, onSave, onDelete }) { + const currentUser = useSelector((state) => state.user); + const createStatus = useSelector((state) => state.documentCreate); + const updateStatus = useSelector((state) => state.documentUpdate); + + const userDefaultVis = currentUser?.isPrivate === '1' ? 'private' + : currentUser?.isProtected === '1' ? 'protected' : 'public'; + + const [title, setTitle] = useState(doc.title); + const [slug, setSlug] = useState(doc.slug); + const [body, setBody] = useState(doc.body || ''); + const [tags, setTags] = useState(doc.tags || []); + const [tagInput, setTagInput] = useState(''); + const [visibility, setVisibility] = useState(doc.visibility || userDefaultVis); + const [showTags, setShowTags] = useState(false); + const [showSource, setShowSource] = useState(false); + const [dirty, setDirty] = useState(false); + const [editStart, setEditStart] = useState(-1); + const [editEnd, setEditEnd] = useState(-1); + const [editValue, setEditValue] = useState(''); + const [editMinH, setEditMinH] = useState(40); + const taRef = useRef(null); + const bodyRef = useRef(null); + const fileInputRef = useRef(null); + + const isNew = !doc.id || doc.id === '__sample__'; + const isOwner = doc.createdBy && currentUser && currentUser.id === doc.createdBy; + const canEdit = isNew || isOwner; + const markDirty = useCallback(() => setDirty(true), []); + const username = doc.createdByUsername || (doc.createdBy && currentUser?.username); + const documentsUrl = username ? `/docs/${username}/${slug}` : `/docs/${slug}`; + + const handleAddTag = useCallback((t) => { + const tag = t.trim().toLowerCase(); + if (tag && !tags.includes(tag)) { setTags([...tags, tag]); markDirty(); } + }, [tags, markDirty]); + + useEffect(() => { + setTitle(doc.title); + setSlug(doc.slug); + setBody(doc.body || ''); + setTags(doc.tags || []); + setVisibility(doc.visibility || userDefaultVis); + setShowSource(false); setDirty(false); + setEditStart(-1); setEditEnd(-1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doc.id, doc.title, doc.slug, doc.body, doc.tags, doc.visibility]); + + useEffect(() => { + if (!title.trim() && body) { + const m = body.match(/^#\s+(.+)$/m); + if (m) setTitle(m[1].trim()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [body]); + + useEffect(() => { + const el = bodyRef.current; + if (!el || showSource) return; + const handler = (e) => { + const cb = e.target.closest('.doc-task-check'); + if (!cb) return; + e.preventDefault(); + const blockEl = cb.closest('[data-start]'); + if (!blockEl) return; + const start = parseInt(blockEl.dataset.start, 10); + const end = parseInt(blockEl.dataset.end, 10); + if (isNaN(start) || isNaN(end)) return; + const lines = body.split('\n'); + for (let j = start; j < end; j++) { + const line = lines[j]; + const idx = line.indexOf('- ['); + if (idx >= 0) { + const close = line.indexOf(']', idx); + if (close >= 0) { + const checked = line[idx + 3] === 'x' ? ' ' : 'x'; + lines[j] = line.slice(0, idx + 3) + checked + line.slice(idx + 4); + setBody(lines.join('\n')); markDirty(); break; + } + } + } + }; + el.addEventListener('click', handler); + return () => el.removeEventListener('click', handler); + }, [body, showSource, markDirty]); + + const startBlockEdit = useCallback((block, e) => { + if (!canEdit) return; + if (e?.target?.tagName === 'INPUT' && e.target.type === 'checkbox') return; + const lines = body.split('\n'); + setEditStart(block.start); setEditEnd(block.end); + setEditValue(lines.slice(block.start, block.end).join('\n')); + if (e) { const h = e.currentTarget.offsetHeight; if (h > 0) setEditMinH(h); } + setTimeout(() => { if (taRef.current) { taRef.current.focus(); taRef.current.setSelectionRange(taRef.current.value.length, taRef.current.value.length); } }, 0); + }, [body, canEdit]); + + const finishEdit = useCallback((save) => { + if (editStart < 0) return; + if (save && editValue) { + const newLines = editValue.replace(/\n+$/, '').split('\n'); + const lines = body.split('\n'); + lines.splice(editStart, editEnd - editStart, ...newLines); + setBody(lines.join('\n')); markDirty(); + } + setEditStart(-1); setEditEnd(-1); setEditValue(''); + }, [editStart, editEnd, editValue, body, markDirty]); + + const handleEditKeyDown = useCallback((e) => { + if (e.key === 'Escape') { e.stopPropagation(); finishEdit(false); } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); finishEdit(true); } + }, [finishEdit]); + + const handleSave = useCallback(() => { + const saveTitle = title.trim() || 'Untitled'; + onSave({ title: saveTitle, slug, body, tags, visibility, isPublished: true }); + }, [title, slug, body, tags, visibility, onSave]); + + const handleExportMd = useCallback(() => { + const blob = new Blob([body], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (title || 'document').replace(/[^a-z0-9_-]/gi, '_') + '.md'; + a.click(); URL.revokeObjectURL(url); + }, [body, title]); + + const handleCopyBody = useCallback(() => { navigator.clipboard.writeText(body).catch(() => {}); }, [body]); + const handleCopyUrl = useCallback(() => { navigator.clipboard.writeText(`${window.location.origin}${documentsUrl}`).catch(() => {}); }, [documentsUrl]); + + const handleImportFile = useCallback((e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target.result; + setBody(text); + const headingMatch = text.match(/^#\s+(.+)$/m); + const baseName = file.name.replace(/\.(md|markdown)$/i, ''); + const source = headingMatch?.[1]?.trim() || baseName; + const auto = source.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-').substring(0, 200) || 'imported'; + setSlug(auto); + setTitle(headingMatch?.[1]?.trim() || baseName || 'Imported'); + markDirty(); + }; + reader.readAsText(file); + e.target.value = ''; + }, [markDirty]); + + const docData = useMemo(() => parseMarkdown(body), [body]); + const blocks = docData.blocks; + const isEditing = editStart >= 0; + const showTimestamps = doc.id && doc.id !== '__sample__' && (doc.createdAt || doc.updatedAt); + const saveErrorMsg = (isNew ? createStatus : updateStatus).errorText; + + return ( +
+
+ {canEdit ? ( + { setTitle(e.target.value); markDirty(); }} placeholder="Document title" /> + ) : ( +

{title}

+ )} +
+ {canEdit && ( + <> + + {dirty && } + {onDelete && } + + )} +
+
+ + {saveErrorMsg &&
{saveErrorMsg}
} + + {showTimestamps && ( +
+ {doc.createdAt && Created {formatDate(doc.createdAt)}} + {doc.updatedAt && doc.updatedAt !== doc.createdAt && Updated {formatDate(doc.updatedAt)}} +
+ )} + +
+ + + {canEdit && ( + <> + + + + )} + + {canEdit && ( +
+ /docs/{username || '…'}/ + { setSlug(e.target.value.replace(/[^a-zA-Z0-9\u0400-\u04FF-]/g, '').substring(0, 200)); markDirty(); }} + placeholder="slug" /> +
+ )} + + {canEdit && ( + + )} + + {!canEdit && visibility !== 'public' && {visibility}} + + {canEdit && ( + + )} + + {doc.id !== '__sample__' && ( + + )} + {doc.id !== '__sample__' && ( + {documentsUrl} + )} +
+ + {showTags && canEdit && ( +
+
+
+ +
+ {tags.map((t) => ( + + {t} + + + ))} +
+ setTagInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (tagInput.trim()) { handleAddTag(tagInput); setTagInput(''); } } }} + placeholder="Add tag..." /> +
+
+
+
+
+ )} + +
+ {showSource ? ( +