Files
author/app/components/Sidebar.js
T

364 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useCallback } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useI18n } from '../lib/useI18n';
import { createChapter, deleteChapter, updateChapter, exportToMarkdown, exportAllToMarkdown } from '../lib/storage';
import { exportProject, importProject } from '../lib/project-io';
import { WRITING_MODES } from '../lib/settings';
export default function Sidebar() {
const {
chapters, addChapter, setChapters, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
sidebarOpen, setSidebarOpen,
theme, setTheme,
writingMode,
setShowSettings,
setShowSnapshots,
showToast
} = useAppStore();
const [renameId, setRenameId] = useState(null);
const [renameTitle, setRenameTitle] = useState('');
const [contextMenu, setContextMenu] = useState(null);
const { t } = useI18n();
// 切换主题
const toggleTheme = useCallback(() => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('author-theme', next);
}, [theme, setTheme]);
// 中文数字 ↔ 阿拉伯数字 互转
const cnDigits = '零一二三四五六七八九十百千万';
const parseCnNum = (s) => {
if (!s) return NaN;
let result = 0, current = 0;
for (const ch of s) {
const d = '零一二三四五六七八九'.indexOf(ch);
if (d >= 0) { current = d || current; }
else if (ch === '十') { result += (current || 1) * 10; current = 0; }
else if (ch === '百') { result += (current || 1) * 100; current = 0; }
else if (ch === '千') { result += (current || 1) * 1000; current = 0; }
else if (ch === '万') { result += (current || 1) * 10000; current = 0; }
}
return result + current;
};
const toCnNum = (n) => {
if (n <= 0) return '零';
if (n <= 10) return '零一二三四五六七八九十'[n];
const units = ['', '十', '百', '千', '万'];
const digits = '零一二三四五六七八九';
let result = '';
let str = String(n);
let len = str.length;
let lastWasZero = false;
for (let i = 0; i < len; i++) {
const d = parseInt(str[i]);
const unit = units[len - 1 - i];
if (d === 0) { lastWasZero = true; }
else {
if (lastWasZero) result += '零';
if (d === 1 && unit === '十' && result === '') result += unit;
else result += digits[d] + unit;
lastWasZero = false;
}
}
return result;
};
// 尝试从标题提取数字并生成下一章标题,返回 null 表示无法匹配
const tryNextTitle = (title) => {
// 1. "第N章" 阿拉伯数字
const m1 = title.match(/第(\d+)章/);
if (m1) return title.replace(/第\d+章/, `第${parseInt(m1[1], 10) + 1}章`);
// 2. "第X章" 中文数字(如 第三十三章)
const m2 = title.match(/第([零一二三四五六七八九十百千万]+)章/);
if (m2) { const n = parseCnNum(m2[1]); if (!isNaN(n)) return title.replace(/第[零一二三四五六七八九十百千万]+章/, `第${toCnNum(n + 1)}章`); }
// 3. 纯阿拉伯数字(如 "33")
if (/^\d+$/.test(title.trim())) return String(parseInt(title.trim(), 10) + 1);
// 4. 纯中文数字(如 "三十三")
if (/^[零一二三四五六七八九十百千万]+$/.test(title.trim())) { const n = parseCnNum(title.trim()); if (!isNaN(n)) return toCnNum(n + 1); }
// 5. 包含末尾数字(如 "Chapter 33")
const m5 = title.match(/(\d+)\s*$/);
if (m5) return title.replace(/(\d+)\s*$/, String(parseInt(m5[1], 10) + 1));
return null;
};
// 从章节列表中向前搜索最近的带数字章节,推算下一章名
const getNextChapterTitle = useCallback(() => {
if (chapters.length === 0) return t('sidebar.defaultChapterTitle').replace('{num}', 1);
// 从最后一章向前找,跳过"更新说明"等非标准章节
for (let i = chapters.length - 1; i >= 0; i--) {
const next = tryNextTitle(chapters[i].title);
if (next) return next;
}
return t('sidebar.defaultChapterTitle').replace('{num}', chapters.length + 1);
}, [chapters, t]);
// 创建新章节 — 一键创建并进入重命名模式
const handleCreateChapter = useCallback(async () => {
const title = getNextChapterTitle();
const ch = await createChapter(title);
addChapter(ch);
setActiveChapterId(ch.id);
// 立即进入重命名模式,方便用户修改标题
setRenameId(ch.id);
setRenameTitle(title);
showToast(t('sidebar.chapterCreated').replace('{title}', title), 'success');
}, [getNextChapterTitle, showToast, addChapter, setActiveChapterId, t]);
// 删除章节
const handleDeleteChapter = useCallback(async (id) => {
if (!Array.isArray(chapters) || chapters.length <= 1) {
showToast(t('sidebar.alertRetainOne'), 'error');
return;
}
const ch = chapters.find(c => c.id === id);
const remaining = await deleteChapter(id);
setChapters(remaining);
if (activeChapterId === id) {
setActiveChapterId(remaining[0]?.id || null);
}
showToast(t('sidebar.chapterDeleted').replace('{title}', ch?.title), 'info');
setContextMenu(null);
}, [chapters, activeChapterId, showToast, setChapters, setActiveChapterId, t]);
// 重命名章节
const handleRename = useCallback((id) => {
const title = renameTitle.trim();
if (!title) return;
const updated = updateChapter(id, { title });
updateChapterStore(id, { title });
setRenameId(null);
setRenameTitle('');
}, [renameTitle, updateChapterStore]);
// 导出
const handleExport = useCallback((type) => {
if (type === 'current' && activeChapterId) {
const activeChapter = chapters.find(ch => ch.id === activeChapterId);
if (activeChapter) {
exportToMarkdown(activeChapter);
showToast(t('sidebar.exportedChapter'), 'success');
}
} else if (type === 'all') {
exportAllToMarkdown(chapters);
showToast(t('sidebar.exportedAll'), 'success');
}
}, [activeChapterId, chapters, showToast, t]);
const totalWords = Array.isArray(chapters) ? chapters.reduce((sum, ch) => sum + (ch.wordCount || 0), 0) : 0;
return (
<>
<aside className={`sidebar ${sidebarOpen ? '' : 'collapsed'}`}>
<div className="sidebar-header">
<div className="sidebar-logo">
<span>A</span>uthor
</div>
<button className="btn btn-ghost btn-icon" onClick={() => setSidebarOpen(false)} title={t('sidebar.collapseSidebar')}>
✕
</button>
</div>
<div style={{ padding: '12px 12px 0' }}>
<button
id="tour-new-chapter"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={handleCreateChapter}
>
{t('sidebar.newChapter')}
</button>
</div>
<div className="sidebar-content">
<div style={{ fontSize: '11px', color: 'var(--text-muted)', padding: '8px 14px 6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('sidebar.chapterList')} ({chapters.length})
</div>
<div className="chapter-list">
{chapters.map(ch => (
<div
key={ch.id}
className={`chapter-item ${ch.id === activeChapterId ? 'active' : ''}`}
onClick={() => setActiveChapterId(ch.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ id: ch.id, x: e.clientX, y: e.clientY });
}}
>
{renameId === ch.id ? (
<input
className="modal-input"
style={{ margin: 0, padding: '4px 8px', fontSize: '13px' }}
value={renameTitle || ''}
onChange={e => setRenameTitle(e.target.value)}
onBlur={() => handleRename(ch.id)}
onKeyDown={e => e.key === 'Enter' && handleRename(ch.id)}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<>
<span className="chapter-title">{ch.title}</span>
<span className="chapter-count">{ch.wordCount || 0}{t('sidebar.wordUnit')}</span>
<div className="chapter-actions">
<button
className="btn btn-ghost btn-icon btn-sm"
onClick={(e) => {
e.stopPropagation();
setRenameId(ch.id);
setRenameTitle(ch.title);
}}
title={t('common.rename')}
>
✎
</button>
<button
className="btn btn-ghost btn-icon btn-sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteChapter(ch.id);
}}
title={t('common.delete')}
style={{ color: 'var(--error)' }}
>
✕
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
<div className="sidebar-footer" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '8px' }}>
{/* 写作模式指示器 */}
{(() => {
const modeConfig = WRITING_MODES[writingMode];
return modeConfig ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
background: `${modeConfig.color}10`,
border: `1px solid ${modeConfig.color}30`,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
onClick={() => setShowSettings(true)}
title={t('sidebar.clickToSwitchMode')}
>
<span style={{ fontSize: '14px' }}>{modeConfig.icon}</span>
<span style={{ fontSize: '12px', fontWeight: '600', color: modeConfig.color }}>{t('sidebar.modeLabel').replace('{mode}', modeConfig.label)}</span>
</div>
) : null;
})()}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--text-muted)' }}>
<span>{t('sidebar.totalWords')}</span>
<span style={{ color: 'var(--accent)', fontWeight: '600' }}>{totalWords.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => handleExport('current')}>
{t('sidebar.exportCurrent')}
</button>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => handleExport('all')}>
{t('sidebar.exportAll')}
</button>
<button id="tour-settings" className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSettings(true)} title={t('sidebar.tooltipSettings')}>
⚙️
</button>
<button className="btn btn-secondary btn-sm btn-icon" onClick={toggleTheme} title={theme === 'light' ? t('sidebar.tooltipThemeDark') : t('sidebar.tooltipThemeLight')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSnapshots(true)} title={t('sidebar.tooltipTimeMachine')}>
🕒
</button>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { exportProject(); }}>
{t('sidebar.btnSave')}
</button>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { document.getElementById('project-import-input')?.click(); }}>
{t('sidebar.btnLoad')}
</button>
<input
id="project-import-input"
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const result = await importProject(file);
if (result.success) {
alert(result.message + '\n' + t('sidebar.importSuccess'));
window.location.reload();
} else {
alert(result.message);
}
e.target.value = '';
}}
/>
</div>
</div>
</aside>
{/* ===== 右键菜单 ===== */}
{contextMenu && (
<div
className="modal-overlay"
style={{ background: 'transparent' }}
onClick={() => setContextMenu(null)}
>
<div
className="dropdown-menu"
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="dropdown-item"
onClick={() => {
setRenameId(contextMenu.id);
const ch = chapters.find(c => c.id === contextMenu.id);
setRenameTitle(ch?.title || '');
setContextMenu(null);
}}
>
{t('sidebar.contextRename')}
</button>
<button
className="dropdown-item"
onClick={() => {
const ch = chapters.find(c => c.id === contextMenu.id);
if (ch) exportToMarkdown(ch);
setContextMenu(null);
}}
>
{t('sidebar.contextExport')}
</button>
<button
className="dropdown-item danger"
onClick={() => handleDeleteChapter(contextMenu.id)}
>
{t('sidebar.contextDelete')}
</button>
</div>
</div>
)}
</>
);
}