776 lines
21 KiB
Vue
776 lines
21 KiB
Vue
<template>
|
||
<div class="pdf-reader-container" :class="className">
|
||
<!-- 主阅读区域 - 更宽的展示空间 -->
|
||
<div class="pdf-reader-content">
|
||
<!-- PDF内容展示 -->
|
||
<div class="pdf-viewer-container">
|
||
<PDFPage
|
||
:page-number="currentPage"
|
||
:scale="scale"
|
||
:highlights="showHighlights ? annotations.highlights : []"
|
||
:selected-highlight-id="selectedHighlight?.id || null"
|
||
:on-highlight-click="handleHighlightClick"
|
||
:on-add-highlight="handleAddHighlight"
|
||
:on-text-content="handleTextContent"
|
||
:read-only="readOnly"
|
||
:pdf-source="file"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 右侧批注面板 - 文档视图分离 -->
|
||
<div v-if="showSidebar" class="annotation-sidebar">
|
||
<div class="annotation-sidebar-header">
|
||
<h4 class="annotation-sidebar-title">批注管理</h4>
|
||
<button
|
||
@click="showSidebar = false"
|
||
class="annotation-sidebar-close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="annotation-sidebar-content">
|
||
<!-- 高亮批注列表 -->
|
||
<div v-if="annotations.highlights.length > 0" class="annotation-section">
|
||
<div class="annotation-section-title">高亮批注</div>
|
||
<div class="annotation-list">
|
||
<div
|
||
v-for="highlight in annotations.highlights"
|
||
:key="highlight.id"
|
||
class="annotation-item"
|
||
:style="{ borderLeft: `3px solid ${highlight.color}` }"
|
||
@click="selectHighlight(highlight)"
|
||
>
|
||
<div class="annotation-item-content">
|
||
<div class="annotation-item-text">
|
||
{{ highlight.content?.substring(0, 50) }}{{ highlight.content?.length > 50 ? '...' : '' }}
|
||
</div>
|
||
<div class="annotation-item-meta">
|
||
<span>第{{ highlight.pageNumber }}页</span>
|
||
<button
|
||
@click.stop="handleDeleteHighlight(highlight.id)"
|
||
class="annotation-delete-btn"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 关联的评论 -->
|
||
<div v-if="getHighlightComments(highlight.id).length > 0" class="annotation-comments">
|
||
<div
|
||
v-for="comment in getHighlightComments(highlight.id)"
|
||
:key="comment.id"
|
||
class="annotation-comment-item"
|
||
>
|
||
<div class="annotation-comment-text">{{ comment.text }}</div>
|
||
<div class="annotation-comment-meta">
|
||
{{ new Date(comment.createdAt).toLocaleString() }}
|
||
<button
|
||
@click.stop="handleDeleteComment(comment.id)"
|
||
class="annotation-delete-btn"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 独立评论列表 -->
|
||
<div v-if="independentComments.length > 0" class="annotation-section">
|
||
<div class="annotation-section-title">独立评论</div>
|
||
<div class="annotation-list">
|
||
<div
|
||
v-for="comment in independentComments"
|
||
:key="comment.id"
|
||
class="annotation-item"
|
||
@click="goToCommentPage(comment.pageNumber)"
|
||
>
|
||
<div class="annotation-item-content">
|
||
<div class="annotation-item-text">{{ comment.text }}</div>
|
||
<div class="annotation-item-meta">
|
||
<span>第{{ comment.pageNumber || '未知' }}页</span>
|
||
<button
|
||
@click.stop="handleDeleteComment(comment.id)"
|
||
class="annotation-delete-btn"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-if="annotations.highlights.length === 0 && independentComments.length === 0" class="annotation-empty">
|
||
<p>暂无批注</p>
|
||
<p class="annotation-empty-text">选择文本添加高亮或批注</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批注弹窗 -->
|
||
<CommentPopup
|
||
v-if="showCommentPopup && selectedHighlight"
|
||
:comments="getHighlightComments(selectedHighlight.id)"
|
||
:position="commentPopupPosition"
|
||
:on-add-comment="handleAddComment"
|
||
:on-delete-comment="handleDeleteComment"
|
||
:on-close="() => {
|
||
showCommentPopup = false
|
||
selectedHighlight = null
|
||
}"
|
||
:on-delete-highlight="handleDeleteHighlight"
|
||
:highlight-content="selectedHighlight.content"
|
||
/>
|
||
|
||
<!-- 操作按钮(朗读、MinerU提取等) -->
|
||
<div v-if="showCommentPopup && selectedHighlight?.content" class="action-buttons-container">
|
||
<button
|
||
@click="handleSpeak"
|
||
class="action-button"
|
||
:disabled="!isSpeechAvailable"
|
||
>
|
||
{{ isSpeaking ? '停止朗读' : '朗读' }}
|
||
</button>
|
||
<button
|
||
@click="() => handleMineruExtract(selectedHighlight.pageNumber)"
|
||
class="action-button action-button-success"
|
||
:disabled="isMineruProcessing"
|
||
>
|
||
{{ isMineruProcessing ? '提取中...' : 'MinerU提取' }}
|
||
</button>
|
||
<div v-if="mineruError" class="error-message error-message-danger">
|
||
{{ mineruError }}
|
||
</div>
|
||
<div v-if="mineruExtractedContent[selectedHighlight.pageNumber]" class="error-message error-message-success">
|
||
<strong>MinerU提取结果:</strong>
|
||
<p>{{ mineruExtractedContent[selectedHighlight.pageNumber].substring(0, 100) }}...</p>
|
||
</div>
|
||
<div v-if="errorMessage" class="error-message error-message-danger">
|
||
{{ errorMessage }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 侧边栏切换按钮 -->
|
||
<button
|
||
@click="showSidebar = !showSidebar"
|
||
class="sidebar-toggle-button"
|
||
>
|
||
{{ showSidebar ? '收起批注' : '显示批注' }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import CommentPopup from './CommentPopup.vue'
|
||
import PDFPage from './PDFPage.vue'
|
||
import {
|
||
createAnnotationData,
|
||
addHighlight,
|
||
removeHighlight,
|
||
addComment,
|
||
removeComment,
|
||
exportAnnotations,
|
||
importAnnotations
|
||
} from '../../utils/annotationUtils'
|
||
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis'
|
||
|
||
const props = defineProps({
|
||
file: {
|
||
type: [String, Object, ArrayBuffer],
|
||
required: true
|
||
},
|
||
initialAnnotations: {
|
||
type: Object,
|
||
default: null
|
||
},
|
||
onAnnotationsChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
readOnly: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
className: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
currentMode: {
|
||
type: String,
|
||
default: 'select'
|
||
},
|
||
selectedColor: {
|
||
type: String,
|
||
default: null
|
||
},
|
||
currentPage: {
|
||
type: Number,
|
||
default: undefined
|
||
},
|
||
numPages: {
|
||
type: Number,
|
||
default: undefined
|
||
},
|
||
scale: {
|
||
type: Number,
|
||
default: undefined
|
||
},
|
||
onPageChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
onScaleChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
onNumPagesChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
onHighlightClick: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
showHighlights: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
isReading: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
readingSpeed: {
|
||
type: Number,
|
||
default: 1
|
||
},
|
||
readingRange: {
|
||
type: String,
|
||
default: 'current'
|
||
},
|
||
onReadingStatusChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
onReadingProgressChange: {
|
||
type: Function,
|
||
default: null
|
||
},
|
||
onReadingTextChange: {
|
||
type: Function,
|
||
default: null
|
||
}
|
||
})
|
||
|
||
const migrateInitialAnnotations = (data) => {
|
||
if (!data) return createAnnotationData()
|
||
|
||
const migratedHighlights = data.highlights?.map((h) => {
|
||
if ('positions' in h) {
|
||
return h
|
||
}
|
||
if ('position' in h) {
|
||
return {
|
||
...h,
|
||
positions: [h.position]
|
||
}
|
||
}
|
||
return h
|
||
}) || []
|
||
|
||
return {
|
||
...data,
|
||
highlights: migratedHighlights,
|
||
version: '2.0.0'
|
||
}
|
||
}
|
||
|
||
const numPages = ref(props.numPages || 0)
|
||
const currentPage = ref(props.currentPage || 1)
|
||
const scale = ref(props.scale || 1)
|
||
const annotations = ref(migrateInitialAnnotations(props.initialAnnotations))
|
||
const selectedHighlight = ref(null)
|
||
const commentPopupPosition = reactive({ x: 0, y: 0 })
|
||
const showCommentPopup = ref(false)
|
||
const showSidebar = ref(false)
|
||
const showControls = ref(false)
|
||
const pageTextContent = reactive({})
|
||
const mineruExtractedContent = reactive({})
|
||
const isMineruProcessing = ref(false)
|
||
const mineruError = ref(null)
|
||
|
||
// 计算属性:独立评论(没有关联高亮的评论)
|
||
const independentComments = computed(() => {
|
||
return annotations.value.comments.filter(comment => !comment.highlightId)
|
||
})
|
||
|
||
// 选择高亮并跳转到对应页面
|
||
const selectHighlight = (highlight) => {
|
||
selectedHighlight.value = highlight
|
||
currentPage.value = highlight.pageNumber
|
||
if (props.onPageChange) {
|
||
props.onPageChange(highlight.pageNumber)
|
||
}
|
||
}
|
||
|
||
// 跳转到评论所在页面
|
||
const goToCommentPage = (pageNumber) => {
|
||
if (pageNumber) {
|
||
currentPage.value = pageNumber
|
||
if (props.onPageChange) {
|
||
props.onPageChange(pageNumber)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按ID删除高亮
|
||
const handleDeleteHighlight = (highlightId) => {
|
||
const highlightToDelete = highlightId || (selectedHighlight.value?.id)
|
||
if (highlightToDelete) {
|
||
updateAnnotations(removeHighlight(annotations.value, highlightToDelete))
|
||
showCommentPopup.value = false
|
||
selectedHighlight.value = null
|
||
}
|
||
}
|
||
|
||
const {
|
||
speak,
|
||
pause,
|
||
resume,
|
||
stop,
|
||
isSpeaking,
|
||
isPaused,
|
||
speakingProgress,
|
||
setRate,
|
||
setVolume,
|
||
isSpeechAvailable,
|
||
errorMessage
|
||
} = useSpeechSynthesis()
|
||
|
||
watch(() => props.currentPage, (newPage) => {
|
||
if (newPage !== undefined) {
|
||
currentPage.value = newPage
|
||
}
|
||
})
|
||
|
||
watch(() => props.numPages, (newPages) => {
|
||
if (newPages !== undefined) {
|
||
numPages.value = newPages
|
||
}
|
||
})
|
||
|
||
watch(() => props.scale, (newScale) => {
|
||
if (newScale !== undefined) {
|
||
scale.value = newScale
|
||
}
|
||
})
|
||
|
||
const updateAnnotations = (newData) => {
|
||
annotations.value = newData
|
||
if (props.onAnnotationsChange) {
|
||
props.onAnnotationsChange(newData)
|
||
}
|
||
}
|
||
|
||
const handleTextContent = (pageNum, textContent) => {
|
||
pageTextContent[pageNum] = textContent
|
||
}
|
||
|
||
const extractTextFromPage = (pageNum) => {
|
||
const content = pageTextContent[pageNum]
|
||
if (!content) return ''
|
||
|
||
return content.items.map(item => item.str).join(' ')
|
||
}
|
||
|
||
const handleReadingToggle = () => {
|
||
console.log('handleReadingToggle called, isSpeaking:', isSpeaking.value)
|
||
if (isSpeaking.value) {
|
||
stop()
|
||
if (props.onReadingStatusChange) {
|
||
props.onReadingStatusChange(false)
|
||
}
|
||
} else {
|
||
let text = ''
|
||
let description = ''
|
||
|
||
console.log('Current page:', currentPage.value)
|
||
console.log('Reading range:', props.readingRange)
|
||
|
||
if (props.readingRange === 'selected') {
|
||
if (selectedHighlight.value?.content) {
|
||
text = selectedHighlight.value.content
|
||
description = '选中文本'
|
||
} else {
|
||
console.log('No text selected')
|
||
if (props.onReadingTextChange) {
|
||
props.onReadingTextChange('请先选择要朗读的文本')
|
||
}
|
||
return
|
||
}
|
||
} else if (props.readingRange === 'current') {
|
||
text = extractTextFromPage(currentPage.value)
|
||
description = `第${currentPage.value}页`
|
||
} else if (props.readingRange === 'chapter') {
|
||
console.log('Page text content:', pageTextContent)
|
||
const chapterPages = []
|
||
for (let i = currentPage.value; i <= Math.min(currentPage.value + 8, numPages.value); i++) {
|
||
chapterPages.push(i)
|
||
}
|
||
chapterPages.forEach(pageNum => {
|
||
text += extractTextFromPage(pageNum) + ' '
|
||
})
|
||
description = `第${currentPage.value}-${Math.min(currentPage.value + 8, numPages.value)}页`
|
||
} else if (props.readingRange === 'full') {
|
||
console.log('Page text content:', pageTextContent)
|
||
for (let i = 1; i <= numPages.value; i++) {
|
||
text += extractTextFromPage(i) + ' '
|
||
}
|
||
description = `全文(共${numPages.value}页)`
|
||
}
|
||
|
||
console.log('Extracted text:', text)
|
||
console.log('Description:', description)
|
||
|
||
if (text && text.trim().length > 0) {
|
||
if (props.onReadingTextChange) {
|
||
props.onReadingTextChange(`正在朗读${description}`)
|
||
}
|
||
console.log('Calling speak with text length:', text.length)
|
||
console.log('First 100 characters:', text.substring(0, 100))
|
||
|
||
const maxTextLength = 3000
|
||
const speakingText = text.length > maxTextLength ? text.substring(0, maxTextLength) + '...' : text
|
||
|
||
speak(speakingText, {
|
||
rate: props.readingSpeed,
|
||
onProgress: (progress) => {
|
||
console.log('Reading progress:', progress)
|
||
if (props.onReadingProgressChange) {
|
||
props.onReadingProgressChange(progress)
|
||
}
|
||
}
|
||
})
|
||
if (props.onReadingStatusChange) {
|
||
props.onReadingStatusChange(true)
|
||
}
|
||
} else {
|
||
console.log('No text extracted, cannot speak')
|
||
if (props.onReadingTextChange) {
|
||
props.onReadingTextChange('无法提取文本,请检查PDF文件')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
watch([() => props.isReading, isSpeaking], ([newIsReading, newIsSpeaking]) => {
|
||
if (newIsReading && !newIsSpeaking) {
|
||
handleReadingToggle()
|
||
} else if (!newIsReading && newIsSpeaking) {
|
||
stop()
|
||
if (props.onReadingStatusChange) {
|
||
props.onReadingStatusChange(false)
|
||
}
|
||
}
|
||
})
|
||
|
||
const handleReadingPause = () => {
|
||
if (isSpeaking.value) {
|
||
pause()
|
||
} else if (isPaused.value) {
|
||
resume()
|
||
}
|
||
}
|
||
|
||
const handleReadingStop = () => {
|
||
stop()
|
||
}
|
||
|
||
const handleZoomIn = () => {
|
||
const newScale = Math.min(scale.value + 0.25, 3)
|
||
scale.value = newScale
|
||
if (props.onScaleChange) {
|
||
props.onScaleChange(newScale)
|
||
}
|
||
}
|
||
|
||
const handleZoomOut = () => {
|
||
const newScale = Math.max(scale.value - 0.25, 0.5)
|
||
scale.value = newScale
|
||
if (props.onScaleChange) {
|
||
props.onScaleChange(newScale)
|
||
}
|
||
}
|
||
|
||
const handleZoomReset = () => {
|
||
scale.value = 1
|
||
if (props.onScaleChange) {
|
||
props.onScaleChange(1)
|
||
}
|
||
}
|
||
|
||
const handlePreviousPage = () => {
|
||
const newPage = Math.max(currentPage.value - 1, 1)
|
||
currentPage.value = newPage
|
||
if (props.onPageChange) {
|
||
props.onPageChange(newPage)
|
||
}
|
||
}
|
||
|
||
const handleNextPage = () => {
|
||
const newPage = Math.min(currentPage.value + 1, numPages.value)
|
||
currentPage.value = newPage
|
||
if (props.onPageChange) {
|
||
props.onPageChange(newPage)
|
||
}
|
||
}
|
||
|
||
const handlePageChange = (page) => {
|
||
const newPage = parseInt(page)
|
||
if (!isNaN(newPage) && newPage >= 1 && newPage <= numPages.value) {
|
||
currentPage.value = newPage
|
||
if (props.onPageChange) {
|
||
props.onPageChange(newPage)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleAddHighlight = (highlight) => {
|
||
if (props.readOnly) return
|
||
|
||
if (props.currentMode === 'highlight') {
|
||
const highlightWithColor = {
|
||
...highlight,
|
||
color: props.selectedColor || '#ffeb3b'
|
||
}
|
||
updateAnnotations(addHighlight(annotations.value, highlightWithColor))
|
||
} else if (props.currentMode === 'comment') {
|
||
selectedHighlight.value = highlight
|
||
const position = highlight.screenPosition || { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||
const adjustedX = Math.max(20, Math.min(position.x, window.innerWidth - 320))
|
||
const adjustedY = Math.max(20, Math.min(position.y, window.innerHeight - 500))
|
||
commentPopupPosition.x = adjustedX
|
||
commentPopupPosition.y = adjustedY
|
||
showCommentPopup.value = true
|
||
}
|
||
}
|
||
|
||
const handleHighlightClick = (highlight, e) => {
|
||
selectedHighlight.value = highlight
|
||
commentPopupPosition.x = e.clientX
|
||
commentPopupPosition.y = e.clientY
|
||
showCommentPopup.value = true
|
||
if (props.onHighlightClick) {
|
||
props.onHighlightClick(highlight)
|
||
}
|
||
}
|
||
|
||
const handleAddComment = (text, shouldAddHighlight = false, highlightColor = '#ffeb3b') => {
|
||
let newAnnotations = annotations.value
|
||
let highlightId = null
|
||
|
||
if (shouldAddHighlight && selectedHighlight.value) {
|
||
const highlightWithColor = {
|
||
...selectedHighlight.value,
|
||
color: highlightColor
|
||
}
|
||
newAnnotations = addHighlight(newAnnotations, highlightWithColor)
|
||
highlightId = newAnnotations.highlights[newAnnotations.highlights.length - 1].id
|
||
} else if (selectedHighlight.value && selectedHighlight.value.id) {
|
||
highlightId = selectedHighlight.value.id
|
||
}
|
||
|
||
if (highlightId) {
|
||
const updatedAnnotations = addComment(newAnnotations, highlightId, text, selectedHighlight.value?.pageNumber, selectedHighlight.value?.content)
|
||
updateAnnotations(updatedAnnotations)
|
||
} else {
|
||
const updatedAnnotations = addComment(newAnnotations, null, text, currentPage.value, null)
|
||
updateAnnotations(updatedAnnotations)
|
||
}
|
||
}
|
||
|
||
const handleDeleteComment = (commentId) => {
|
||
updateAnnotations(removeComment(annotations.value, commentId))
|
||
}
|
||
|
||
|
||
|
||
const handleExport = () => {
|
||
exportAnnotations(annotations.value)
|
||
}
|
||
|
||
const handleImport = async (file) => {
|
||
try {
|
||
const data = await importAnnotations(file)
|
||
updateAnnotations(data)
|
||
} catch (err) {
|
||
console.error('Failed to import annotations:', err)
|
||
alert('导入批注失败,请检查文件格式')
|
||
}
|
||
}
|
||
|
||
const handleMineruExtract = async (pageNumber) => {
|
||
isMineruProcessing.value = true
|
||
mineruError.value = null
|
||
|
||
try {
|
||
console.log('开始使用MinerU提取第', pageNumber, '页内容')
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||
|
||
const mockContent = `这是使用MinerU提取的第${pageNumber}页内容。\n\nMinerU能够智能识别PDF文档的结构,包括标题、段落、表格等元素,并将其转换为结构化的文本格式。\n\n这种方式比传统的OCR更加准确,能够更好地保留文档的原始结构和格式。`
|
||
|
||
mineruExtractedContent[pageNumber] = mockContent
|
||
|
||
console.log('MinerU提取完成:', mockContent)
|
||
} catch (error) {
|
||
console.error('MinerU提取失败:', error)
|
||
mineruError.value = '内容提取失败,请检查MinerU服务是否运行'
|
||
} finally {
|
||
isMineruProcessing.value = false
|
||
}
|
||
}
|
||
|
||
const handleSpeak = () => {
|
||
if (selectedHighlight.value?.content) {
|
||
console.log('Speaking selected highlight content:', selectedHighlight.value.content)
|
||
speak(selectedHighlight.value.content, {
|
||
onProgress: (progress) => {
|
||
console.log('Selected text reading progress:', progress)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
const getHighlightComments = (highlightId) => {
|
||
return annotations.value.comments.filter(c => c.highlightId === highlightId)
|
||
}
|
||
|
||
defineExpose({
|
||
handleZoomIn,
|
||
handleZoomOut,
|
||
handleZoomReset,
|
||
handlePreviousPage,
|
||
handleNextPage,
|
||
handlePageChange,
|
||
handleReadingToggle,
|
||
handleReadingPause,
|
||
handleReadingStop,
|
||
handleExport,
|
||
handleImport
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
@import './pdfReader.css';
|
||
|
||
.pdf-reader-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100%;
|
||
background: var(--bg-page);
|
||
}
|
||
|
||
.pdf-reader-content {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.pdf-viewer-container {
|
||
flex: 1;
|
||
padding: var(--space-lg);
|
||
overflow: hidden;
|
||
display: flex;
|
||
justify-content: center;
|
||
max-width: 100%;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.annotation-sidebar,
|
||
.sidebar-toggle-button,
|
||
.action-buttons-container {
|
||
display: none !important;
|
||
}
|
||
|
||
.action-button {
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--color-primary);
|
||
color: var(--text-inverse);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: var(--font-md);
|
||
}
|
||
|
||
.action-button:hover:not(:disabled) {
|
||
background: var(--color-primary-hover);
|
||
}
|
||
|
||
.action-button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.action-button-success {
|
||
background: var(--color-success);
|
||
}
|
||
|
||
.action-button-success:hover:not(:disabled) {
|
||
background: var(--color-success-hover);
|
||
}
|
||
|
||
.error-message {
|
||
padding: var(--space-xs) var(--space-sm);
|
||
border-radius: var(--radius-sm);
|
||
font-size: var(--font-xs);
|
||
margin-top: var(--space-xs);
|
||
max-width: 300px;
|
||
text-align: center;
|
||
}
|
||
|
||
.error-message-danger {
|
||
background: var(--bg-error);
|
||
color: var(--color-error);
|
||
border: 1px solid var(--border-error);
|
||
}
|
||
|
||
.error-message-success {
|
||
background: var(--bg-success);
|
||
color: var(--color-success);
|
||
border: 1px solid var(--border-success);
|
||
max-width: 400px;
|
||
}
|
||
|
||
.error-message p {
|
||
margin: var(--space-xs) 0 0 0;
|
||
font-size: var(--font-xs);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.sidebar-toggle-button {
|
||
position: fixed;
|
||
bottom: var(--space-lg);
|
||
right: var(--space-lg);
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--color-primary);
|
||
color: var(--text-inverse);
|
||
border: none;
|
||
border-radius: var(--radius-lg);
|
||
cursor: pointer;
|
||
font-size: var(--font-md);
|
||
z-index: 1000;
|
||
}
|
||
|
||
.sidebar-toggle-button:hover {
|
||
background: var(--color-primary-hover);
|
||
}
|
||
</style>
|