feat: wire up real-time collaboration and document sharing
- CalcEditor: accept ytext + awareness props for Y.js collaborative editing
with remote cursors via yCollab
- App: integrate share button, CollabIndicator, route handling for /s/{token}
- App: create cloud documents on share, resolve share tokens on open
- Share button visible when authenticated, opens ShareDialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Workspace layout: header → tab bar → editor + results panel.
|
* Workspace layout: header → tab bar → editor + results panel.
|
||||||
* Multi-document support via document store with localStorage persistence.
|
* Multi-document support via document store with localStorage persistence.
|
||||||
|
* Supports real-time collaboration via Y.js when authenticated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, useRef, useEffect } from 'react'
|
import { useCallback, useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import type { EditorView } from '@codemirror/view'
|
import type { EditorView } from '@codemirror/view'
|
||||||
import { CalcEditor } from './editor/CalcEditor.tsx'
|
import { CalcEditor } from './editor/CalcEditor.tsx'
|
||||||
import { useEngine } from './engine/useEngine.ts'
|
import { useEngine } from './engine/useEngine.ts'
|
||||||
@@ -28,8 +29,16 @@ import { useFontSize } from './hooks/useFontSize.ts'
|
|||||||
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
|
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
|
||||||
import { UserMenu } from './components/UserMenu.tsx'
|
import { UserMenu } from './components/UserMenu.tsx'
|
||||||
import { SecuritySettings } from './components/SecuritySettings.tsx'
|
import { SecuritySettings } from './components/SecuritySettings.tsx'
|
||||||
|
import { CollabIndicator } from './components/CollabIndicator.tsx'
|
||||||
import { useAuth } from './auth/AuthProvider.tsx'
|
import { useAuth } from './auth/AuthProvider.tsx'
|
||||||
import { AuthModal } from './auth/AuthModal.tsx'
|
import { AuthModal } from './auth/AuthModal.tsx'
|
||||||
|
import { ShareDialog } from './sharing/ShareDialog.tsx'
|
||||||
|
import { useShareToken } from './sharing/useShareToken.ts'
|
||||||
|
import { useCloudSync } from './sync/useCloudSync.ts'
|
||||||
|
import { useYDoc } from './collab/useYDoc.ts'
|
||||||
|
import { useWebSocketProvider } from './collab/useWebSocketProvider.ts'
|
||||||
|
import { getUserColor, getDisplayName } from './collab/awareness.ts'
|
||||||
|
import { useRoute, navigate } from './router/useRoute.ts'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -40,9 +49,13 @@ function App() {
|
|||||||
const fontSizeCtx = useFontSize()
|
const fontSizeCtx = useFontSize()
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
|
const cloudSync = useCloudSync()
|
||||||
|
const route = useRoute()
|
||||||
|
const { resolveShareToken } = useShareToken()
|
||||||
|
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false)
|
const [showAuthModal, setShowAuthModal] = useState(false)
|
||||||
const [showSecuritySettings, setShowSecuritySettings] = useState(false)
|
const [showSecuritySettings, setShowSecuritySettings] = useState(false)
|
||||||
|
const [showShareDialog, setShowShareDialog] = useState(false)
|
||||||
|
|
||||||
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
||||||
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -51,6 +64,10 @@ function App() {
|
|||||||
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
|
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
|
||||||
const [formatPreview, setFormatPreview] = useState(true)
|
const [formatPreview, setFormatPreview] = useState(true)
|
||||||
|
|
||||||
|
// Cloud document tracking
|
||||||
|
const [cloudDocId, setCloudDocId] = useState<string | null>(null)
|
||||||
|
const [sharedDocTitle, setSharedDocTitle] = useState<string | null>(null)
|
||||||
|
|
||||||
// Sidebar state
|
// Sidebar state
|
||||||
const [sidebarState, setSidebarState] = useState(loadSidebarState)
|
const [sidebarState, setSidebarState] = useState(loadSidebarState)
|
||||||
const setSidebarVisible = useCallback((v: boolean) => {
|
const setSidebarVisible = useCallback((v: boolean) => {
|
||||||
@@ -71,29 +88,85 @@ function App() {
|
|||||||
const [dividerX, setDividerX] = useState<number | null>(null)
|
const [dividerX, setDividerX] = useState<number | null>(null)
|
||||||
const isDragging = useRef(false)
|
const isDragging = useRef(false)
|
||||||
|
|
||||||
|
// --- Collaboration setup ---
|
||||||
|
const collabDocId = cloudDocId ?? (route.type === 'shared' ? route.token : null)
|
||||||
|
const { ytext, ready: ydocReady, initContent } = useYDoc(collabDocId)
|
||||||
|
|
||||||
|
const userName = useMemo(() => getDisplayName(auth.user?.email), [auth.user?.email])
|
||||||
|
const userColor = useMemo(() => getUserColor(auth.user?.id ?? 'anon'), [auth.user?.id])
|
||||||
|
|
||||||
|
const { awareness, connected, peerCount } = useWebSocketProvider({
|
||||||
|
ydoc: collabDocId ? (ytext as any)?.doc ?? null : null,
|
||||||
|
roomName: collabDocId ? `doc:${collabDocId}` : null,
|
||||||
|
token: auth.session?.access_token ?? null,
|
||||||
|
userName,
|
||||||
|
userColor,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCollaborating = !!collabDocId && !!ytext && ydocReady
|
||||||
|
|
||||||
|
// Initialize Y.Doc with current content when starting collaboration
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCollaborating && store.activeDoc) {
|
||||||
|
initContent(store.activeDoc.content)
|
||||||
|
}
|
||||||
|
}, [isCollaborating]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// --- Handle shared document route ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.type !== 'shared') return
|
||||||
|
|
||||||
|
resolveShareToken(route.token).then(info => {
|
||||||
|
if (info) {
|
||||||
|
setCloudDocId(info.id)
|
||||||
|
setSharedDocTitle(info.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [route]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// --- Share current document ---
|
||||||
|
const handleShare = useCallback(async () => {
|
||||||
|
if (!auth.isAuthenticated || !store.activeDoc) {
|
||||||
|
setShowAuthModal(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cloud document if not exists
|
||||||
|
if (!cloudDocId) {
|
||||||
|
const cloudDoc = await cloudSync.createCloudDocument(store.activeDoc.title)
|
||||||
|
if (cloudDoc) {
|
||||||
|
setCloudDocId(cloudDoc.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowShareDialog(true)
|
||||||
|
}, [auth.isAuthenticated, store.activeDoc, cloudDocId, cloudSync])
|
||||||
|
|
||||||
const handleDocChange = useCallback(
|
const handleDocChange = useCallback(
|
||||||
(lines: string[]) => {
|
(lines: string[]) => {
|
||||||
engine.evalSheet(lines)
|
engine.evalSheet(lines)
|
||||||
// Persist content
|
// Persist content (only in non-shared mode)
|
||||||
const content = lines.join('\n')
|
if (route.type !== 'shared') {
|
||||||
if (store.activeDoc && content !== store.activeDoc.content) {
|
const content = lines.join('\n')
|
||||||
store.updateContent(store.activeTabId, content)
|
if (store.activeDoc && content !== store.activeDoc.content) {
|
||||||
setModifiedIds(prev => {
|
store.updateContent(store.activeTabId, content)
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(store.activeTabId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
// Clear modified dot after save debounce
|
|
||||||
setTimeout(() => {
|
|
||||||
setModifiedIds(prev => {
|
setModifiedIds(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(store.activeTabId)
|
next.add(store.activeTabId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, 500)
|
// Clear modified dot after save debounce
|
||||||
|
setTimeout(() => {
|
||||||
|
setModifiedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(store.activeTabId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
|
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent, route.type],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Switch tabs
|
// Switch tabs
|
||||||
@@ -101,20 +174,23 @@ function App() {
|
|||||||
if (id === store.activeTabId) return
|
if (id === store.activeTabId) return
|
||||||
store.setActiveTab(id)
|
store.setActiveTab(id)
|
||||||
setEditorKey(id)
|
setEditorKey(id)
|
||||||
}, [store.activeTabId, store.setActiveTab])
|
setCloudDocId(null) // Reset collab when switching tabs
|
||||||
|
if (route.type === 'shared') navigate('/')
|
||||||
|
}, [store.activeTabId, store.setActiveTab, route.type])
|
||||||
|
|
||||||
// New document
|
// New document
|
||||||
const handleNewTab = useCallback(() => {
|
const handleNewTab = useCallback(() => {
|
||||||
const doc = store.createDocument()
|
const doc = store.createDocument()
|
||||||
setEditorKey(doc.id)
|
setEditorKey(doc.id)
|
||||||
}, [store.createDocument])
|
setCloudDocId(null)
|
||||||
|
if (route.type === 'shared') navigate('/')
|
||||||
|
}, [store.createDocument, route.type])
|
||||||
|
|
||||||
// Close tab
|
// Close tab
|
||||||
const handleTabClose = useCallback((id: string) => {
|
const handleTabClose = useCallback((id: string) => {
|
||||||
store.closeTab(id)
|
store.closeTab(id)
|
||||||
// If we closed the active tab, editorKey needs updating
|
|
||||||
if (id === store.activeTabId) {
|
if (id === store.activeTabId) {
|
||||||
// State will update, trigger effect below
|
setCloudDocId(null)
|
||||||
}
|
}
|
||||||
}, [store.closeTab, store.activeTabId])
|
}, [store.closeTab, store.activeTabId])
|
||||||
|
|
||||||
@@ -253,6 +329,8 @@ function App() {
|
|||||||
? { flex: 1 }
|
? { flex: 1 }
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
const docTitle = sharedDocTitle ?? store.activeDoc?.title ?? 'Untitled'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="calcpad-app">
|
<div className="calcpad-app">
|
||||||
<OfflineBanner isOnline={isOnline} />
|
<OfflineBanner isOnline={isOnline} />
|
||||||
@@ -287,6 +365,24 @@ function App() {
|
|||||||
min={fontSizeCtx.MIN_SIZE}
|
min={fontSizeCtx.MIN_SIZE}
|
||||||
max={fontSizeCtx.MAX_SIZE}
|
max={fontSizeCtx.MAX_SIZE}
|
||||||
/>
|
/>
|
||||||
|
{isCollaborating && (
|
||||||
|
<CollabIndicator connected={connected} peerCount={peerCount} />
|
||||||
|
)}
|
||||||
|
{auth.isAuthenticated && (
|
||||||
|
<button
|
||||||
|
className="header-share-btn"
|
||||||
|
onClick={handleShare}
|
||||||
|
title="Share document"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<ThemePicker
|
<ThemePicker
|
||||||
theme={themeCtx.theme}
|
theme={themeCtx.theme}
|
||||||
resolvedTheme={themeCtx.resolvedTheme}
|
resolvedTheme={themeCtx.resolvedTheme}
|
||||||
@@ -351,6 +447,8 @@ function App() {
|
|||||||
debounceMs={50}
|
debounceMs={50}
|
||||||
onViewReady={setEditorView}
|
onViewReady={setEditorView}
|
||||||
formatPreview={formatPreview}
|
formatPreview={formatPreview}
|
||||||
|
ytext={isCollaborating ? ytext : null}
|
||||||
|
awareness={isCollaborating ? awareness : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -398,6 +496,16 @@ function App() {
|
|||||||
{showSecuritySettings && (
|
{showSecuritySettings && (
|
||||||
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
|
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showShareDialog && cloudDocId && (
|
||||||
|
<ShareDialog
|
||||||
|
documentId={cloudDocId}
|
||||||
|
documentTitle={docTitle}
|
||||||
|
currentShareToken={null}
|
||||||
|
currentSharePermission={null}
|
||||||
|
onClose={() => setShowShareDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Integrates the CalcPad language mode, error display,
|
* Integrates the CalcPad language mode, error display,
|
||||||
* and debounced evaluation via the WASM engine Web Worker.
|
* and debounced evaluation via the WASM engine Web Worker.
|
||||||
|
* Supports Y.js collaborative editing when ytext + awareness are provided.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useEffect, useCallback } from 'react'
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
@@ -27,9 +28,12 @@ import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-
|
|||||||
import { stripedLinesExtension } from './inline-results.ts'
|
import { stripedLinesExtension } from './inline-results.ts'
|
||||||
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
|
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
|
||||||
import type { EngineLineResult } from '../engine/types.ts'
|
import type { EngineLineResult } from '../engine/types.ts'
|
||||||
|
import type * as Y from 'yjs'
|
||||||
|
import type { Awareness } from 'y-protocols/awareness'
|
||||||
|
import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next'
|
||||||
|
|
||||||
export interface CalcEditorProps {
|
export interface CalcEditorProps {
|
||||||
/** Initial document content */
|
/** Initial document content (ignored when ytext is provided) */
|
||||||
initialDoc?: string
|
initialDoc?: string
|
||||||
/** Called when the document text changes (debounced internally) */
|
/** Called when the document text changes (debounced internally) */
|
||||||
onDocChange?: (lines: string[]) => void
|
onDocChange?: (lines: string[]) => void
|
||||||
@@ -41,11 +45,16 @@ export interface CalcEditorProps {
|
|||||||
onViewReady?: (view: EditorView | null) => void
|
onViewReady?: (view: EditorView | null) => void
|
||||||
/** Enable live preview formatting */
|
/** Enable live preview formatting */
|
||||||
formatPreview?: boolean
|
formatPreview?: boolean
|
||||||
|
/** Y.js text for collaborative editing */
|
||||||
|
ytext?: Y.Text | null
|
||||||
|
/** Y.js awareness for remote cursors */
|
||||||
|
awareness?: Awareness | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CalcPad editor component built on CodeMirror 6.
|
* CalcPad editor component built on CodeMirror 6.
|
||||||
* Handles syntax highlighting, line numbers, and error underlines.
|
* Handles syntax highlighting, line numbers, and error underlines.
|
||||||
|
* When ytext is provided, enables real-time collaborative editing.
|
||||||
*/
|
*/
|
||||||
export function CalcEditor({
|
export function CalcEditor({
|
||||||
initialDoc = '',
|
initialDoc = '',
|
||||||
@@ -54,6 +63,8 @@ export function CalcEditor({
|
|||||||
debounceMs = 50,
|
debounceMs = 50,
|
||||||
onViewReady,
|
onViewReady,
|
||||||
formatPreview = true,
|
formatPreview = true,
|
||||||
|
ytext,
|
||||||
|
awareness,
|
||||||
}: CalcEditorProps) {
|
}: CalcEditorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const viewRef = useRef<EditorView | null>(null)
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
@@ -83,47 +94,78 @@ export function CalcEditor({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const state = EditorState.create({
|
// Build extensions list
|
||||||
doc: initialDoc,
|
const extensions = [
|
||||||
extensions: [
|
lineNumbers(),
|
||||||
lineNumbers(),
|
drawSelection(),
|
||||||
drawSelection(),
|
highlightActiveLine(),
|
||||||
highlightActiveLine(),
|
bracketMatching(),
|
||||||
bracketMatching(),
|
indentOnInput(),
|
||||||
indentOnInput(),
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
history(),
|
syntaxHighlighting(calcpadHighlight),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
calcpadLanguage(),
|
||||||
syntaxHighlighting(calcpadHighlight),
|
errorDisplayExtension(),
|
||||||
calcpadLanguage(),
|
stripedLinesExtension(),
|
||||||
errorDisplayExtension(),
|
formatPreviewExtension(formatPreview),
|
||||||
stripedLinesExtension(),
|
updateListener,
|
||||||
formatPreviewExtension(formatPreview),
|
calcpadEditorTheme,
|
||||||
updateListener,
|
]
|
||||||
calcpadEditorTheme,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const view = new EditorView({
|
// Collaborative mode: use yCollab instead of history
|
||||||
state,
|
if (ytext && awareness) {
|
||||||
parent: containerRef.current,
|
extensions.push(
|
||||||
})
|
yCollab(ytext, awareness),
|
||||||
|
keymap.of(yUndoManagerKeymap),
|
||||||
|
)
|
||||||
|
|
||||||
viewRef.current = view
|
const state = EditorState.create({
|
||||||
onViewReady?.(view)
|
doc: ytext.toString(),
|
||||||
|
extensions,
|
||||||
|
})
|
||||||
|
|
||||||
// Trigger initial evaluation
|
const view = new EditorView({
|
||||||
const doc = view.state.doc.toString()
|
state,
|
||||||
onDocChangeRef.current?.(doc.split('\n'))
|
parent: containerRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
|
viewRef.current = view
|
||||||
|
onViewReady?.(view)
|
||||||
|
|
||||||
|
const doc = view.state.doc.toString()
|
||||||
|
onDocChangeRef.current?.(doc.split('\n'))
|
||||||
|
} else {
|
||||||
|
// Standard mode: local history
|
||||||
|
extensions.splice(4, 0, history()) // Insert after indentOnInput
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialDoc,
|
||||||
|
extensions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: containerRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
|
viewRef.current = view
|
||||||
|
onViewReady?.(view)
|
||||||
|
|
||||||
|
// Trigger initial evaluation
|
||||||
|
const doc = view.state.doc.toString()
|
||||||
|
onDocChangeRef.current?.(doc.split('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
view.destroy()
|
if (viewRef.current) {
|
||||||
viewRef.current = null
|
viewRef.current.destroy()
|
||||||
|
viewRef.current = null
|
||||||
|
}
|
||||||
onViewReady?.(null)
|
onViewReady?.(null)
|
||||||
}
|
}
|
||||||
// initialDoc intentionally excluded — we only set it once on mount
|
// initialDoc intentionally excluded — we only set it once on mount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [scheduleEval])
|
}, [scheduleEval, ytext, awareness])
|
||||||
|
|
||||||
// Toggle format preview mode
|
// Toggle format preview mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -237,4 +279,12 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
|||||||
'.cm-focused': {
|
'.cm-focused': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
|
// Remote cursor styles for collaboration
|
||||||
|
'.cm-ySelectionInfo': {
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--sans)',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px 3px 3px 0',
|
||||||
|
opacity: '0.9',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,6 +43,29 @@
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Share button ---------- */
|
||||||
|
|
||||||
|
.header-share-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-share-btn:hover {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Sidebar toggle ---------- */
|
/* ---------- Sidebar toggle ---------- */
|
||||||
|
|
||||||
.header-sidebar-toggle {
|
.header-sidebar-toggle {
|
||||||
|
|||||||
Reference in New Issue
Block a user