chore: commit remaining workspace changes

This commit is contained in:
2026-05-08 21:51:29 +02:00
parent 8d108b8a76
commit ff96ef796e
97 changed files with 18020 additions and 2196 deletions

View File

@@ -20,7 +20,6 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
const isEdited = post?.is_edited
const postId = post?.id
const threadSlug = thread?.slug
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)

View File

@@ -46,6 +46,21 @@ function Divider() {
return <div className="mx-1 h-5 w-px bg-white/10" />
}
function getRootFontSizePx() {
if (typeof window === 'undefined') {
return 16
}
return Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
}
function formatViewportHeightLabel(value) {
const rounded = Number(value || 0)
const displayValue = Number.isInteger(rounded) ? rounded : Number(rounded.toFixed(1))
return `${displayValue}rem`
}
function normalizeHttpUrl(rawValue) {
const trimmed = String(rawValue || '').trim()
if (trimmed === '') {
@@ -922,10 +937,16 @@ function AssetPickerDialog({
function Toolbar({
editor,
advancedNews = false,
sourceMode = false,
activeSourceMode = null,
sourceModeLabel = 'HTML',
sourceModeTitle = 'View or edit source HTML',
secondarySourceModeLabel = null,
secondarySourceModeTitle = '',
showStructureOutlines = false,
showComparisonTool = false,
fullHeightMode = false,
onToggleSourceMode,
onToggleSecondarySourceMode,
onToggleStructureOutlines,
onInsertArtwork,
onInsertImage,
@@ -937,7 +958,7 @@ function Toolbar({
editorViewportHeight,
onIncreaseEditorViewportHeight,
onDecreaseEditorViewportHeight,
onResetEditorViewportHeight,
onToggleFullHeightMode,
}) {
if (!editor) return null
@@ -1027,9 +1048,14 @@ function Toolbar({
{advancedNews ? (
<>
<Divider />
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
<ToolbarBtn onClick={onToggleSourceMode} active={activeSourceMode === 'primary'} title={sourceModeTitle} className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{sourceModeLabel}</span>
</ToolbarBtn>
{secondarySourceModeLabel ? (
<ToolbarBtn onClick={onToggleSecondarySourceMode} active={activeSourceMode === 'secondary'} title={secondarySourceModeTitle} className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{secondarySourceModeLabel}</span>
</ToolbarBtn>
) : null}
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
</ToolbarBtn>
@@ -1053,12 +1079,12 @@ function Toolbar({
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A-</span>
</ToolbarBtn>
<div className="mx-1 flex min-w-[5.25rem] items-center justify-center rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
{editorViewportHeight}rem
{formatViewportHeightLabel(editorViewportHeight)}
</div>
<ToolbarBtn onClick={onIncreaseEditorViewportHeight} title="Taller editor" className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A+</span>
</ToolbarBtn>
<ToolbarBtn onClick={onResetEditorViewportHeight} title="Reset editor height" className="w-auto px-2.5">
<ToolbarBtn onClick={onToggleFullHeightMode} active={fullHeightMode} title={fullHeightMode ? 'Exit full height editor' : 'Expand editor to full browser size'} className="w-auto px-2.5">
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Fit</span>
</ToolbarBtn>
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
@@ -1078,16 +1104,26 @@ export default function RichTextEditor({
placeholder = 'Write something…',
error,
minHeight = 12,
maxHeightRem = 42,
autofocus = false,
advancedNews = false,
sourceModeLabel = 'HTML',
sourceModeTitle = 'View or edit source HTML',
sourceModeDescription = 'Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.',
secondarySourceModeLabel = null,
secondarySourceModeTitle = '',
secondarySourceModeDescription = '',
secondarySourceModeValue = null,
onSecondarySourceModeValueChange = null,
searchEntities = null,
mediaSupport = null,
}) {
const viewportStorageKey = 'rich-text-editor.viewport-height'
const viewportMinHeight = Math.max(minHeight + 6, 18)
const viewportMaxHeight = 42
const viewportMaxHeight = Math.max(viewportMinHeight, Number(maxHeightRem) || 42)
const viewportStep = 4
const [sourceMode, setSourceMode] = useState(false)
const [activeSourceMode, setActiveSourceMode] = useState(null)
const [fullHeightMode, setFullHeightMode] = useState(false)
const [sourceValue, setSourceValue] = useState(String(content || ''))
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
const [helperMessage, setHelperMessage] = useState('')
@@ -1142,6 +1178,8 @@ export default function RichTextEditor({
return Math.min(viewportMaxHeight, viewportMinHeight)
})
const editorRef = useRef(null)
const resizeCleanupRef = useRef(null)
const usesSecondarySourceMode = typeof onSecondarySourceModeValueChange === 'function' && secondarySourceModeValue != null
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') {
return ''
@@ -1491,7 +1529,7 @@ export default function RichTextEditor({
},
},
onUpdate: ({ editor: currentEditor }) => {
if (!sourceMode) {
if (!activeSourceMode) {
onChange?.(currentEditor.getHTML())
}
},
@@ -1520,7 +1558,7 @@ export default function RichTextEditor({
useEffect(() => {
if (!editor) return
if (sourceMode) return
if (activeSourceMode) return
if ((content || '') === editor.getHTML()) return
editor.commands.setContent(content || '', false)
@@ -1529,13 +1567,13 @@ export default function RichTextEditor({
if (normalizedHtml !== (content || '')) {
onChange?.(normalizedHtml)
}
}, [content, editor, onChange, sourceMode])
}, [activeSourceMode, content, editor, onChange])
useEffect(() => {
if (sourceMode) {
if (activeSourceMode === 'primary') {
setSourceValue(String(content || editor?.getHTML() || ''))
}
}, [content, editor, sourceMode])
}, [activeSourceMode, content, editor])
useEffect(() => {
if (typeof window === 'undefined') {
@@ -1544,6 +1582,42 @@ export default function RichTextEditor({
window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight))
}, [editorViewportHeight])
const stopViewportResize = useCallback(() => {
if (resizeCleanupRef.current) {
resizeCleanupRef.current()
resizeCleanupRef.current = null
}
if (typeof document !== 'undefined') {
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
}, [])
useEffect(() => stopViewportResize, [stopViewportResize])
useEffect(() => {
if (!fullHeightMode || typeof window === 'undefined') {
return undefined
}
const previousOverflow = document.body.style.overflow
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setFullHeightMode(false)
}
}
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', handleKeyDown)
}
}, [fullHeightMode])
const decreaseEditorViewportHeight = useCallback(() => {
setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1))))
}, [viewportMinHeight, viewportStep])
@@ -1552,27 +1626,98 @@ export default function RichTextEditor({
setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1))))
}, [viewportMaxHeight, viewportStep])
const resetEditorViewportHeight = useCallback(() => {
setEditorViewportHeight(Math.min(viewportMaxHeight, viewportMinHeight))
}, [viewportMaxHeight, viewportMinHeight])
const toggleFullHeightMode = useCallback(() => {
setFullHeightMode((current) => !current)
}, [])
const startViewportResize = useCallback((event) => {
if (fullHeightMode || event.button !== 0 || typeof window === 'undefined') {
return
}
event.preventDefault()
const startY = event.clientY
const startHeight = editorViewportHeight
const handlePointerMove = (moveEvent) => {
const deltaRem = (moveEvent.clientY - startY) / getRootFontSizePx()
const nextHeight = Number((startHeight + deltaRem).toFixed(1))
setEditorViewportHeight(Math.min(viewportMaxHeight, Math.max(viewportMinHeight, nextHeight)))
}
const handlePointerUp = () => {
stopViewportResize()
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
resizeCleanupRef.current = () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
document.body.style.userSelect = 'none'
document.body.style.cursor = 'ns-resize'
}, [editorViewportHeight, fullHeightMode, stopViewportResize, viewportMaxHeight, viewportMinHeight])
const pushHelperMessage = useCallback((message) => {
setHelperMessage(message)
}, [])
const commitPrimarySourceToEditor = useCallback(() => {
if (editor) {
editor.commands.setContent(sourceValue || '', false)
}
}, [editor, sourceValue])
const handleToggleSourceMode = useCallback(() => {
if (sourceMode) {
setSourceMode(false)
if (editor) {
editor.commands.setContent(sourceValue || '', false)
}
if (activeSourceMode === 'primary') {
setActiveSourceMode(null)
commitPrimarySourceToEditor()
pushHelperMessage('Returned to visual editor.')
return
}
setSourceValue(editor?.getHTML() || String(content || ''))
setSourceMode(true)
}, [content, editor, pushHelperMessage, sourceMode, sourceValue])
if (activeSourceMode === 'secondary') {
setActiveSourceMode('primary')
setSourceValue(String(content || editor?.getHTML() || ''))
return
}
setSourceValue(String(content || editor?.getHTML() || ''))
setActiveSourceMode('primary')
}, [activeSourceMode, commitPrimarySourceToEditor, content, editor, pushHelperMessage])
const handleToggleSecondarySourceMode = useCallback(() => {
if (!usesSecondarySourceMode) {
return
}
if (activeSourceMode === 'secondary') {
setActiveSourceMode(null)
pushHelperMessage('Returned to visual editor.')
return
}
if (activeSourceMode === 'primary') {
commitPrimarySourceToEditor()
}
setActiveSourceMode('secondary')
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage, usesSecondarySourceMode])
const handleCloseSourceMode = useCallback(() => {
if (activeSourceMode === 'primary') {
commitPrimarySourceToEditor()
}
setActiveSourceMode(null)
pushHelperMessage('Returned to visual editor.')
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage])
const insertArtworkEmbed = useCallback((item) => {
if (!editor || !item) return
@@ -1867,23 +2012,52 @@ export default function RichTextEditor({
editor.chain().focus().insertContent(`#${value}`).run()
}, [editor])
const shellClassName = fullHeightMode
? 'fixed inset-0 z-[980] flex min-h-0 w-screen flex-col bg-[#04070df2] p-3 backdrop-blur-sm md:p-4'
: 'flex w-full min-w-0 flex-col gap-1.5'
const bodyClassName = fullHeightMode
? 'flex min-h-0 w-full flex-1 flex-col gap-1.5'
: 'flex w-full min-w-0 flex-col gap-1.5'
const editorCardClassName = [
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
fullHeightMode ? 'flex min-h-0 flex-1 flex-col rounded-2xl' : '',
error
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
].filter(Boolean).join(' ')
const editorViewportStyle = fullHeightMode
? { flex: 1 }
: { height: `${editorViewportHeight}rem` }
const sourceTextareaStyle = fullHeightMode
? { flex: 1 }
: {
height: `${Math.max(minHeight, editorViewportHeight)}rem`,
minHeight: `${Math.max(minHeight, 20)}rem`,
}
return (
<div className="flex w-full min-w-0 flex-col gap-1.5">
<div className={shellClassName}>
<div className={bodyClassName}>
<div
className={[
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
error
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
].join(' ')}
className={editorCardClassName}
>
<Toolbar
editor={editor}
advancedNews={advancedNews}
sourceMode={sourceMode}
activeSourceMode={activeSourceMode}
sourceModeLabel={sourceModeLabel}
sourceModeTitle={sourceModeTitle}
secondarySourceModeLabel={secondarySourceModeLabel}
secondarySourceModeTitle={secondarySourceModeTitle}
showStructureOutlines={showStructureOutlines}
showComparisonTool={Boolean(mediaSupport?.uploadUrl)}
fullHeightMode={fullHeightMode}
onToggleSourceMode={handleToggleSourceMode}
onToggleSecondarySourceMode={handleToggleSecondarySourceMode}
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
onInsertArtwork={handleInsertArtwork}
onInsertImage={handleInsertImage}
@@ -1895,44 +2069,65 @@ export default function RichTextEditor({
editorViewportHeight={editorViewportHeight}
onIncreaseEditorViewportHeight={increaseEditorViewportHeight}
onDecreaseEditorViewportHeight={decreaseEditorViewportHeight}
onResetEditorViewportHeight={resetEditorViewportHeight}
onToggleFullHeightMode={toggleFullHeightMode}
/>
{advancedNews && sourceMode ? (
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
{advancedNews && activeSourceMode ? (
<div className={[
'border-t border-white/[0.04] bg-black/10 px-4 py-3',
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
].filter(Boolean).join(' ')}>
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
<span>{activeSourceMode === 'secondary' ? secondarySourceModeDescription : sourceModeDescription}</span>
<button type="button" onClick={handleCloseSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
Back to visual
</button>
</div>
<textarea
value={sourceValue}
value={activeSourceMode === 'secondary' ? String(secondarySourceModeValue || '') : sourceValue}
onChange={(event) => {
const nextValue = event.target.value
if (activeSourceMode === 'secondary') {
onSecondarySourceModeValueChange?.(nextValue)
return
}
setSourceValue(nextValue)
onChange?.(nextValue)
}}
spellCheck={false}
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
className="nova-scrollbar w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
style={sourceTextareaStyle}
/>
</div>
) : (
<div className={[
'rich-text-editor-viewport nova-scrollbar w-full min-w-0 border-t border-white/[0.04] bg-black/15',
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
advancedNews && showStructureOutlines ? 'news-editor-outline' : '',
].filter(Boolean).join(' ')} style={{ maxHeight: `${editorViewportHeight}rem` }}>
].filter(Boolean).join(' ')} style={editorViewportStyle}>
<EditorContent editor={editor} />
</div>
)}
{!fullHeightMode ? (
<button
type="button"
aria-label="Resize editor height"
title="Drag to resize editor height"
onPointerDown={startViewportResize}
className="group flex h-5 w-full cursor-row-resize items-center justify-center border-t border-white/[0.04] bg-black/10 text-slate-500 transition hover:bg-white/[0.03] hover:text-slate-300"
>
<span className="h-1 w-16 rounded-full bg-current opacity-70 transition group-hover:opacity-100" />
</button>
) : null}
</div>
{advancedNews && helperMessage ? (
<p className="text-xs text-sky-300">{helperMessage}</p>
) : null}
{!sourceMode ? (
{!activeSourceMode ? (
<RichTableControls editor={editor} />
) : null}
@@ -2028,6 +2223,7 @@ export default function RichTextEditor({
onClose={() => setTableInsertOpen(false)}
onInsert={handleTableInsert}
/>
</div>
</div>
)
}

View File

@@ -11,7 +11,6 @@ export default function ThreadRow({ thread, isFirst = false }) {
const isPinned = thread?.is_pinned ?? false
const href = `/forum/topic/${slug}`
return (
<a
href={href}