661 lines
19 KiB
Vue
661 lines
19 KiB
Vue
<template>
|
||
<el-container class="home-page" :class="{'is-mobile': isMobile}">
|
||
<!-- Sidebar -->
|
||
<SidebarMenu
|
||
:is-collapsed="isCollapsed"
|
||
:is-mobile="isMobile"
|
||
:active-menu="activeMenu"
|
||
:category-tree="categoryTree"
|
||
@toggle-collapse="isCollapsed = !isCollapsed"
|
||
@show-create-group="showCreateGroupDialog = true"
|
||
@select-file="handleSelectFile"
|
||
@show-rename-dialog="openRenameDialog"
|
||
@group-deleted="handleGroupDeleted"
|
||
@show-system-settings="showSystemSettingsDialog = true"
|
||
@show-update-password="showUpdatePasswordDialog = true"
|
||
@logout="handleLogout"
|
||
/>
|
||
|
||
<!-- Main Content -->
|
||
<el-container>
|
||
<el-main class="content">
|
||
<!-- Note Editor View -->
|
||
<NoteEditor
|
||
v-if="showEditor"
|
||
:edit-data="editData"
|
||
@back="handleEditorBack"
|
||
@update:edit-data="handleSaveSuccess"
|
||
/>
|
||
|
||
<!-- Note Preview View -->
|
||
<NotePreview
|
||
v-else-if="selectedFile"
|
||
:file="selectedFile"
|
||
:is-mobile="isMobile"
|
||
:is-user-logged-in="userStore.isLoggedIn"
|
||
@back="selectedFile = null"
|
||
@edit="editNote(selectedFile)"
|
||
@delete="deleteNote(selectedFile)"
|
||
@open-rename-dialog="openRenameDialog"
|
||
@show-move-note-dialog="showMoveNoteDialog = true"
|
||
@show-privacy-dialog="showPrivacyDialog = true"
|
||
@export="handleExport"
|
||
/>
|
||
|
||
<!-- Home/List View -->
|
||
<div v-else class="list-view-container">
|
||
<HomeHeader
|
||
:is-mobile="isMobile"
|
||
v-model:search-keyword="searchKeyword"
|
||
@search="handleSearch"
|
||
@reset-view="resetToHomeView"
|
||
@logout="handleLogout"
|
||
@show-update-password="showUpdatePasswordDialog = true"
|
||
@show-system-settings="showSystemSettingsDialog = true"
|
||
@show-create-note="showCreateNoteDialog = true"
|
||
@upload-markdown="handleMarkdownUpload"
|
||
@toggle-collapse="isCollapsed = !isCollapsed"
|
||
/>
|
||
<div class="note-list-wrapper" ref="noteListWrapper" @scroll="handleScroll">
|
||
<NoteList
|
||
:files="displayedFiles"
|
||
:is-user-logged-in="userStore.isLoggedIn"
|
||
@preview="previewFile"
|
||
/>
|
||
<div v-if="isLoadingMore" class="loading-more">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span>加载中...</span>
|
||
</div>
|
||
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-trigger">
|
||
<el-button @click="loadMoreFiles" type="primary" plain>加载更多</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-main>
|
||
</el-container>
|
||
|
||
<!-- Sidebar Overlay for Mobile -->
|
||
<div v-if="isMobile && !isCollapsed" class="sidebar-overlay" @click="isCollapsed = true"></div>
|
||
|
||
<!-- Mobile FAB for creating note -->
|
||
<el-button
|
||
v-if="isMobile && userStore.isLoggedIn && !showEditor && !selectedFile"
|
||
class="fab"
|
||
type="primary"
|
||
circle
|
||
@click="showCreateNoteDialog = true"
|
||
>
|
||
<el-icon><Plus /></el-icon>
|
||
</el-button>
|
||
|
||
<!-- Dialogs -->
|
||
<CreateGroupDialog
|
||
v-model:visible="showCreateGroupDialog"
|
||
:category-options="categoryTree"
|
||
@group-created="fetchGroupings"
|
||
/>
|
||
<CreateNoteDialog
|
||
v-model:visible="showCreateNoteDialog"
|
||
:category-options="categoryTree"
|
||
@create-note="handleCreateNote"
|
||
/>
|
||
<RenameDialog
|
||
v-model:visible="showRenameDialog"
|
||
:item="itemToRename"
|
||
@renamed="handleRenamed"
|
||
/>
|
||
<SelectGroupDialog
|
||
v-model:visible="showSelectGroupDialog"
|
||
:category-options="categoryTree"
|
||
:file-to-import="fileToImport"
|
||
@import-success="handleImportSuccess"
|
||
/>
|
||
<MoveNoteDialog
|
||
v-model:visible="showMoveNoteDialog"
|
||
:category-options="categoryTree"
|
||
:note-to-move="selectedFile"
|
||
@move-success="handleMoveSuccess"
|
||
/>
|
||
<SystemSettingsDialog v-model:visible="showSystemSettingsDialog" />
|
||
<UpdatePasswordDialog
|
||
v-model:visible="showUpdatePasswordDialog"
|
||
@password-updated="handleLogout"
|
||
/>
|
||
<PrivacyDialog
|
||
v-model:visible="showPrivacyDialog"
|
||
:note="selectedFile"
|
||
@privacy-changed="handlePrivacyChanged"
|
||
/>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import Vditor from 'vditor';
|
||
import 'vditor/dist/index.css';
|
||
import {
|
||
groupingAll,
|
||
markdownList,
|
||
Preview,
|
||
updateMarkdown,
|
||
searchMarkdown,
|
||
deleteMarkdown,
|
||
getRecentFiles,
|
||
validateToken,
|
||
} from '@/api/CommonApi.js';
|
||
import { useUserStore } from '../stores/user';
|
||
import { useRouter } from 'vue-router';
|
||
import { privateNoteContent } from '../utils/privateNoteContent.js';
|
||
import jsPDF from 'jspdf';
|
||
import html2canvas from 'html2canvas';
|
||
|
||
// Import Components
|
||
import SidebarMenu from './home/SidebarMenu.vue';
|
||
import HomeHeader from './home/HomeHeader.vue';
|
||
import NoteList from './home/NoteList.vue';
|
||
import NotePreview from './home/NotePreview.vue';
|
||
import NoteEditor from './home/NoteEditor.vue';
|
||
import CreateGroupDialog from './home/dialogs/CreateGroupDialog.vue';
|
||
import CreateNoteDialog from './home/dialogs/CreateNoteDialog.vue';
|
||
import RenameDialog from './home/dialogs/RenameDialog.vue';
|
||
import SelectGroupDialog from './home/dialogs/SelectGroupDialog.vue';
|
||
import MoveNoteDialog from './home/dialogs/MoveNoteDialog.vue';
|
||
import SystemSettingsDialog from './home/dialogs/SystemSettingsDialog.vue';
|
||
import UpdatePasswordDialog from './home/dialogs/UpdatePasswordDialog.vue';
|
||
import PrivacyDialog from './home/dialogs/PrivacyDialog.vue';
|
||
import { Plus, Loading } from "@element-plus/icons-vue";
|
||
|
||
// Basic Setup
|
||
const userStore = useUserStore();
|
||
const router = useRouter();
|
||
|
||
// State
|
||
const searchKeyword = ref('');
|
||
const categoryTree = ref([]);
|
||
const groupMarkdownFiles = ref([]);
|
||
const displayedFiles = ref([]);
|
||
const currentPage = ref(0);
|
||
const pageSize = ref(16);
|
||
const isLoadingMore = ref(false);
|
||
const noteListWrapper = ref(null);
|
||
const showEditor = ref(false);
|
||
const selectedFile = ref(null);
|
||
const editData = ref(null);
|
||
const activeMenu = ref('all');
|
||
const isCollapsed = ref(window.innerWidth < 768);
|
||
const isMobile = ref(window.innerWidth < 768);
|
||
|
||
// Dialog visibility
|
||
const showCreateGroupDialog = ref(false);
|
||
const showCreateNoteDialog = ref(false);
|
||
const showRenameDialog = ref(false);
|
||
const showSelectGroupDialog = ref(false);
|
||
const showMoveNoteDialog = ref(false);
|
||
const showSystemSettingsDialog = ref(false);
|
||
const showUpdatePasswordDialog = ref(false);
|
||
const showPrivacyDialog = ref(false);
|
||
|
||
// Data for dialogs
|
||
const itemToRename = ref(null);
|
||
const fileToImport = ref(null);
|
||
|
||
const hasMoreFiles = computed(() => {
|
||
return displayedFiles.value.length < groupMarkdownFiles.value.length;
|
||
});
|
||
|
||
const resetEdit = () => {
|
||
editData.value = null;
|
||
};
|
||
|
||
// --- Core Logic ---
|
||
|
||
// Data Fetching
|
||
const fetchGroupings = async () => {
|
||
try {
|
||
const allCategories = await groupingAll("");
|
||
categoryTree.value = buildTree(allCategories || []);
|
||
} catch (error) {
|
||
categoryTree.value = [];
|
||
}
|
||
};
|
||
|
||
const buildTree = (items) => {
|
||
const tree = [];
|
||
const itemMap = new Map();
|
||
items.forEach(item => {
|
||
itemMap.set(String(item.id), { ...item, children: [] });
|
||
});
|
||
itemMap.forEach(item => {
|
||
const parentId = String(item.parentId);
|
||
if (parentId !== '0' && itemMap.has(parentId)) {
|
||
itemMap.get(parentId).children.push(item);
|
||
} else {
|
||
tree.push(item);
|
||
}
|
||
});
|
||
const cleanTree = (nodes) => {
|
||
nodes.forEach(node => {
|
||
if (node.children.length === 0) delete node.children;
|
||
else cleanTree(node.children);
|
||
});
|
||
};
|
||
cleanTree(tree);
|
||
return tree;
|
||
};
|
||
|
||
const resetToHomeView = async () => {
|
||
resetEdit();
|
||
selectedFile.value = null;
|
||
showEditor.value = false;
|
||
searchKeyword.value = '';
|
||
activeMenu.value = 'all';
|
||
currentPage.value = 0;
|
||
try {
|
||
groupMarkdownFiles.value = await getRecentFiles(100) || [];
|
||
updateDisplayedFiles();
|
||
} catch (error) {
|
||
ElMessage.error('获取最近文件失败: ' + error.message);
|
||
groupMarkdownFiles.value = [];
|
||
displayedFiles.value = [];
|
||
}
|
||
};
|
||
|
||
const updateDisplayedFiles = () => {
|
||
const start = 0;
|
||
const end = (currentPage.value + 1) * pageSize.value;
|
||
displayedFiles.value = groupMarkdownFiles.value.slice(start, end);
|
||
};
|
||
|
||
const loadMoreFiles = () => {
|
||
if (isLoadingMore.value || !hasMoreFiles.value) return;
|
||
isLoadingMore.value = true;
|
||
|
||
setTimeout(() => {
|
||
currentPage.value++;
|
||
updateDisplayedFiles();
|
||
isLoadingMore.value = false;
|
||
}, 300);
|
||
};
|
||
|
||
const handleScroll = (e) => {
|
||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||
if (scrollHeight - scrollTop - clientHeight < 100 && hasMoreFiles.value && !isLoadingMore.value) {
|
||
loadMoreFiles();
|
||
}
|
||
};
|
||
|
||
// Event Handlers from Components
|
||
const handleSelectFile = async (data) => {
|
||
resetEdit();
|
||
try {
|
||
const files = await markdownList(data.id);
|
||
groupMarkdownFiles.value = files || [];
|
||
currentPage.value = 0;
|
||
updateDisplayedFiles();
|
||
selectedFile.value = null;
|
||
showEditor.value = false;
|
||
activeMenu.value = `group-${data.id}`;
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记列表失败: ' + error.message);
|
||
groupMarkdownFiles.value = [];
|
||
displayedFiles.value = [];
|
||
}
|
||
};
|
||
|
||
const handleGroupDeleted = async () => {
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
};
|
||
|
||
const handleCreateNote = (payload) => {
|
||
resetEdit();
|
||
// 直接设置编辑数据,ID将在第一次保存时由后端生成
|
||
editData.value = payload;
|
||
showEditor.value = true;
|
||
selectedFile.value = null; // Ensure preview is hidden
|
||
};
|
||
|
||
const handleEditorBack = (data) => {
|
||
showEditor.value = false;
|
||
if (data && data.id) {
|
||
const fileWithGrouping = {
|
||
...data,
|
||
groupingName: data.groupingName || getCategoryName(data.groupingId)
|
||
};
|
||
selectedFile.value = fileWithGrouping;
|
||
} else {
|
||
selectedFile.value = null;
|
||
resetToHomeView();
|
||
}
|
||
};
|
||
|
||
const getCategoryName = (groupId) => {
|
||
if (!groupId) return '';
|
||
const findName = (items) => {
|
||
for (const item of items) {
|
||
if (item.id === groupId) return item.grouping;
|
||
if (item.children) {
|
||
const name = findName(item.children);
|
||
if (name) return name;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
return findName(categoryTree.value) || '';
|
||
};
|
||
|
||
const handleSaveSuccess = (updatedFile) => {
|
||
selectedFile.value = updatedFile;
|
||
|
||
const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id);
|
||
if (index !== -1) {
|
||
groupMarkdownFiles.value[index] = updatedFile;
|
||
} else {
|
||
groupMarkdownFiles.value.unshift(updatedFile);
|
||
}
|
||
|
||
updateDisplayedFiles();
|
||
fetchGroupings();
|
||
};
|
||
|
||
const previewFile = async (file) => {
|
||
resetEdit();
|
||
if (!file || file.id === null) {
|
||
editData.value = file;
|
||
selectedFile.value = null;
|
||
return;
|
||
}
|
||
try {
|
||
const content = await Preview(file.id) || '';
|
||
selectedFile.value = { ...file, content };
|
||
showEditor.value = false;
|
||
} catch (error) {
|
||
ElMessage.error('获取笔记内容失败: ' + error.message);
|
||
selectedFile.value = null;
|
||
}
|
||
};
|
||
|
||
const editNote = (file) => {
|
||
editData.value = { ...file };
|
||
showEditor.value = true;
|
||
isCollapsed.value = true; // Collapse sidebar on mobile when editing
|
||
};
|
||
|
||
const deleteNote = (file) => {
|
||
ElMessageBox.confirm(`确定要删除笔记 “${file.title}” 吗?`, '警告', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning',
|
||
}).then(async () => {
|
||
try {
|
||
await deleteMarkdown(file.id);
|
||
ElMessage.success('笔记已删除');
|
||
selectedFile.value = null;
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
} catch (error) {
|
||
ElMessage.error('删除笔记失败: ' + error.message);
|
||
}
|
||
});
|
||
};
|
||
|
||
const openRenameDialog = (item, type) => {
|
||
itemToRename.value = { ...item, type };
|
||
showRenameDialog.value = true;
|
||
};
|
||
|
||
const handleRenamed = async (newName) => {
|
||
await fetchGroupings();
|
||
if (selectedFile.value && itemToRename.value.type === 'file' && selectedFile.value.id === itemToRename.value.id) {
|
||
// 直接更新当前选中文件的标题
|
||
selectedFile.value.title = newName;
|
||
// 更新笔记列表中的对应项
|
||
const index = groupMarkdownFiles.value.findIndex(f => f.id === selectedFile.value.id);
|
||
if (index !== -1) {
|
||
groupMarkdownFiles.value[index].title = newName;
|
||
}
|
||
// 重新获取文件内容以确保是最新的
|
||
previewFile(selectedFile.value); // Refresh preview
|
||
} else {
|
||
resetToHomeView();
|
||
}
|
||
};
|
||
|
||
const handleMarkdownUpload = (file) => {
|
||
fileToImport.value = file;
|
||
showSelectGroupDialog.value = true;
|
||
};
|
||
|
||
const handleImportSuccess = async () => {
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
};
|
||
|
||
const handleMoveSuccess = async () => {
|
||
selectedFile.value = null;
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
};
|
||
|
||
const handlePrivacyChanged = (updatedFile) => {
|
||
selectedFile.value = updatedFile;
|
||
const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id);
|
||
if (index !== -1) {
|
||
groupMarkdownFiles.value[index] = updatedFile;
|
||
}
|
||
};
|
||
|
||
const handleSearch = async () => {
|
||
if (!searchKeyword.value.trim()) {
|
||
await resetToHomeView();
|
||
return;
|
||
}
|
||
try {
|
||
groupMarkdownFiles.value = await searchMarkdown(searchKeyword.value) || [];
|
||
currentPage.value = 0;
|
||
updateDisplayedFiles();
|
||
selectedFile.value = null;
|
||
showEditor.value = false;
|
||
activeMenu.value = 'search';
|
||
} catch (error) {
|
||
ElMessage.error('搜索失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
userStore.logout();
|
||
ElMessage.success('已退出登录');
|
||
router.push('/login');
|
||
};
|
||
|
||
// Export Logic
|
||
const handleExport = async (format) => {
|
||
if (!selectedFile.value) return;
|
||
try {
|
||
await validateToken();
|
||
} catch {
|
||
ElMessage.error('登录已过期,请重新登录');
|
||
handleLogout();
|
||
return;
|
||
}
|
||
|
||
const title = (selectedFile.value.title || '未命名笔记').replace(/[<>:"/\\|?*]/g, '_');
|
||
const content = selectedFile.value.content;
|
||
const previewElement = document.querySelector('.markdown-preview');
|
||
|
||
if (!previewElement) {
|
||
ElMessage.error('无法找到预览区域');
|
||
return;
|
||
}
|
||
|
||
ElMessage.info(`正在导出为 ${format.toUpperCase()}...`);
|
||
|
||
try {
|
||
if (format === 'md') {
|
||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||
downloadBlob(blob, `${title}.md`);
|
||
} else if (format === 'html') {
|
||
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${title}</h1>${previewElement.innerHTML}</body></html>`;
|
||
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
|
||
downloadBlob(blob, `${title}.html`);
|
||
} else if (format === 'pdf') {
|
||
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
|
||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
|
||
const pageHeight = pdf.internal.pageSize.getHeight() - 20;
|
||
const pageWidth = pdf.internal.pageSize.getWidth() - 20;
|
||
const imgHeight = (canvas.height * pageWidth) / canvas.width;
|
||
let heightLeft = imgHeight;
|
||
let position = 10;
|
||
const imgData = canvas.toDataURL('image/png');
|
||
pdf.addImage(imgData, 'PNG', 10, position, pageWidth, imgHeight);
|
||
heightLeft -= pageHeight;
|
||
while (heightLeft > 0) {
|
||
position = -pageHeight * (pdf.internal.getNumberOfPages()) + 10;
|
||
pdf.addPage();
|
||
pdf.addImage(imgData, 'PNG', 10, position, pageWidth, imgHeight);
|
||
heightLeft -= pageHeight;
|
||
}
|
||
pdf.save(`${title}.pdf`);
|
||
}
|
||
ElMessage.success(`${format.toUpperCase()} 导出成功`);
|
||
} catch (error) {
|
||
ElMessage.error(`导出失败: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
const downloadBlob = (blob, filename) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
|
||
// Lifecycle and Watchers
|
||
onMounted(async () => {
|
||
await fetchGroupings();
|
||
await resetToHomeView();
|
||
window.addEventListener('resize', handleResize);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize);
|
||
});
|
||
|
||
const handleResize = () => {
|
||
isMobile.value = window.innerWidth < 768;
|
||
if (isMobile.value) {
|
||
isCollapsed.value = true;
|
||
}
|
||
};
|
||
|
||
// 使用防抖优化 Vditor 渲染
|
||
let renderTimeout = null;
|
||
let lastRenderedId = null;
|
||
|
||
watch([selectedFile, showEditor], ([newFile, newShowEditor]) => {
|
||
if (newFile && !newShowEditor) {
|
||
// 如果同一个文件已经渲染过,跳过
|
||
if (lastRenderedId === newFile.id) return;
|
||
|
||
clearTimeout(renderTimeout);
|
||
renderTimeout = setTimeout(() => {
|
||
nextTick(() => {
|
||
const previewElement = document.querySelector('.markdown-preview');
|
||
if (previewElement) {
|
||
const contentToRender = (newFile.isPrivate === 1 && !userStore.isLoggedIn) ? privateNoteContent : newFile.content;
|
||
Vditor.preview(previewElement, contentToRender || '', {
|
||
mode: 'light',
|
||
hljs: { enable: true, style: 'github' }
|
||
});
|
||
lastRenderedId = newFile.id;
|
||
}
|
||
});
|
||
}, 50); // 50ms 防抖
|
||
}
|
||
}, { deep: true });
|
||
|
||
</script>
|
||
|
||
<style>
|
||
/* Global styles can remain if they are truly global */
|
||
.hide-popper {
|
||
display: none !important;
|
||
}
|
||
.markdown-preview .vditor-reset pre {
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
.markdown-preview .vditor-reset pre code {
|
||
max-height: none !important;
|
||
overflow-y: visible !important;
|
||
word-break: break-all !important;
|
||
white-space: pre-wrap !important;
|
||
display: block;
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
.list-view-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.note-list-wrapper {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.home-page {
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
background: var(--bg-gradient);
|
||
background-attachment: fixed;
|
||
}
|
||
|
||
.content {
|
||
padding: 1.5rem;
|
||
background-color: transparent;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sidebar-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
z-index: 1000;
|
||
}
|
||
|
||
.fab {
|
||
position: fixed;
|
||
bottom: 80px;
|
||
right: 20px;
|
||
z-index: 100;
|
||
width: 56px;
|
||
height: 56px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||
}
|
||
|
||
.loading-more {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
gap: 8px;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.load-more-trigger {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
</style> |