Files
bDS/src/renderer/components/SettingsView/SettingsView.tsx
2026-02-11 09:13:26 +01:00

803 lines
26 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import './SettingsView.css';
// Export category IDs for sidebar navigation
export type SettingsCategory = 'project' | 'editor' | 'content' | 'sync' | 'publishing' | 'data';
// Scroll to a settings section by category ID
export const scrollToSettingsSection = (category: SettingsCategory) => {
const element = document.getElementById(`settings-section-${category}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
// Settings categories
interface Credentials {
// Dropbox File Sync
dropboxAccessToken: string;
dropboxAppKey: string;
dropboxRemotePath: string;
// FTP Publishing
ftpHost: string;
ftpUser: string;
ftpPassword: string;
// SSH Publishing
sshHost: string;
sshUser: string;
sshKeyPath: string;
}
const defaultCredentials: Credentials = {
dropboxAccessToken: '',
dropboxAppKey: '',
dropboxRemotePath: '/blog',
ftpHost: '',
ftpUser: '',
ftpPassword: '',
sshHost: '',
sshUser: '',
sshKeyPath: '',
};
// Search icon for the search bar
const SearchIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M15.25 0a.75.75 0 0 1 .53.22.75.75 0 0 1 0 1.06l-3.25 3.25A6.5 6.5 0 1 1 11.47 3.47l3.25-3.25A.75.75 0 0 1 15.25 0zM6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11z"/>
</svg>
);
// Default post categories based on VISION.md
const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page'];
// Standard categories that cannot be deleted
const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture'];
// Individual setting row component (VS Code style)
const SettingRow: React.FC<{
id: string;
label: string;
description: string;
children: React.ReactNode;
}> = ({ id, label, description, children }) => (
<div className="setting-row" id={`setting-${id}`}>
<div className="setting-info">
<label className="setting-label" htmlFor={id}>{label}</label>
<p className="setting-description">{description}</p>
</div>
<div className="setting-control">
{children}
</div>
</div>
);
// Section header component with optional ID for scrolling
const SettingSection: React.FC<{
id?: string;
title: string;
description?: string;
children: React.ReactNode;
hidden?: boolean;
}> = ({ id, title, description, children, hidden }) => {
if (hidden) return null;
return (
<div className="setting-section" id={id}>
<div className="setting-section-header">
<h3>{title}</h3>
{description && <p className="setting-section-description">{description}</p>}
</div>
<div className="setting-section-content">
{children}
</div>
</div>
);
};
export const SettingsView: React.FC = () => {
const { preferredEditorMode, setPreferredEditorMode, activeProject, setActiveProject } = useAppStore();
const [searchQuery, setSearchQuery] = useState('');
const [credentials, setCredentials] = useState<Credentials>(defaultCredentials);
const [showSecrets, setShowSecrets] = useState(false);
const [dropboxConfigured, setDropboxConfigured] = useState(false);
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Project settings
const [projectName, setProjectName] = useState('');
const [projectDescription, setProjectDescription] = useState('');
// Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
const [newCategoryInput, setNewCategoryInput] = useState('');
// Check if a section has any matching settings
const sectionHasMatches = useCallback((sectionKeywords: string[]) => {
if (!searchQuery) return true;
return sectionKeywords.some(keyword =>
keyword.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [searchQuery]);
// Sync project fields from active project
useEffect(() => {
if (activeProject) {
setProjectName(activeProject.name);
setProjectDescription(activeProject.description || '');
}
}, [activeProject]);
// Load saved credentials and categories
useEffect(() => {
const loadSettings = async () => {
try {
const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) {
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
}
// Load saved post categories
const savedCategories = localStorage.getItem('bds-categories');
if (savedCategories) {
const categories = JSON.parse(savedCategories);
if (Array.isArray(categories) && categories.length > 0) {
setPostCategories(categories);
}
}
// Check Dropbox status
const dbxConfigured = await window.electronAPI?.dropbox?.isConfigured();
setDropboxConfigured(dbxConfigured || false);
if (dbxConfigured) {
const lastSync = await window.electronAPI?.dropbox?.getLastSyncTime();
setDropboxLastSync(lastSync || null);
}
} catch (error) {
console.error('Failed to load settings:', error);
}
};
loadSettings();
}, []);
const handleSaveDropbox = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
if (credentials.dropboxAccessToken && credentials.dropboxAppKey) {
await window.electronAPI?.dropbox?.configure({
accessToken: credentials.dropboxAccessToken,
appKey: credentials.dropboxAppKey,
remotePath: credentials.dropboxRemotePath || '/blog',
});
setDropboxConfigured(true);
showToast.success('Dropbox sync configured');
} else {
showToast.success('Credentials saved');
}
} catch (error) {
console.error('Failed to save Dropbox credentials:', error);
showToast.error('Failed to configure Dropbox sync');
}
};
const handleSavePublishing = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
showToast.success('Publishing credentials saved');
} catch (error) {
console.error('Failed to save publishing credentials:', error);
showToast.error('Failed to save credentials');
}
};
const handleClearCredentials = (type: 'dropbox' | 'ftp' | 'ssh') => {
const newCreds = { ...credentials };
switch (type) {
case 'dropbox':
newCreds.dropboxAccessToken = '';
newCreds.dropboxAppKey = '';
newCreds.dropboxRemotePath = '/blog';
break;
case 'ftp':
newCreds.ftpHost = '';
newCreds.ftpUser = '';
newCreds.ftpPassword = '';
break;
case 'ssh':
newCreds.sshHost = '';
newCreds.sshUser = '';
newCreds.sshKeyPath = '';
break;
}
setCredentials(newCreds);
localStorage.setItem('bds-credentials', JSON.stringify(newCreds));
showToast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} credentials cleared`);
};
const handleDropboxSync = async () => {
try {
showToast.loading('Starting Dropbox sync...');
await window.electronAPI?.dropbox?.syncAll();
showToast.dismiss();
showToast.success('Dropbox sync completed');
const lastSync = await window.electronAPI?.dropbox?.getLastSyncTime();
setDropboxLastSync(lastSync || null);
} catch (error) {
showToast.dismiss();
showToast.error('Dropbox sync failed');
}
};
const handleTestDropboxConnection = async () => {
showToast.loading('Testing Dropbox connection...');
try {
const status = await window.electronAPI?.dropbox?.getStatus();
showToast.dismiss();
if (status) {
showToast.success('Dropbox connection active');
} else {
showToast.error('Dropbox connection failed');
}
} catch {
showToast.dismiss();
showToast.error('Dropbox connection failed');
}
};
// Save project settings
const handleSaveProject = async () => {
if (!activeProject) return;
try {
const updated = await window.electronAPI?.projects.update(activeProject.id, {
name: projectName.trim() || activeProject.name,
description: projectDescription.trim(),
});
if (updated) {
setActiveProject(updated as any);
useAppStore.getState().updateProject(activeProject.id, updated as any);
}
showToast.success('Project settings saved');
} catch (error) {
console.error('Failed to save project settings:', error);
showToast.error('Failed to save project settings');
}
};
// Keywords for each section for search filtering
const projectKeywords = ['project', 'name', 'description', 'blog', 'site'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const syncKeywords = ['sync', 'dropbox', 'file', 'backup', 'token', 'remote'];
const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
const renderProjectSettings = () => (
<SettingSection
id="settings-section-project"
title="Project"
description="General settings for the active blog project."
hidden={!sectionHasMatches(projectKeywords)}
>
<SettingRow
id="project-name"
label="Project Name"
description="The display name of your blog project."
>
<input
id="project-name"
type="text"
placeholder="My Blog"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
</SettingRow>
<SettingRow
id="project-description"
label="Description"
description="A short description of your blog. This can be used in templates and metadata."
>
<textarea
id="project-description"
placeholder="A blog about..."
value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)}
rows={3}
/>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSaveProject}>
Save Project Settings
</button>
</div>
</SettingSection>
);
const renderEditorSettings = () => (
<SettingSection
id="settings-section-editor"
title="Editor"
description="Configure the blog post editor behavior and appearance."
hidden={!sectionHasMatches(editorKeywords)}
>
<SettingRow
id="editor-mode"
label="Default Editor Mode"
description="Choose the default mode when opening posts. You can switch modes at any time using the editor toolbar."
>
<select
id="editor-mode"
value={preferredEditorMode}
onChange={(e) => setPreferredEditorMode(e.target.value as 'wysiwyg' | 'markdown' | 'preview')}
>
<option value="wysiwyg">WYSIWYG (Visual Editor)</option>
<option value="markdown">Markdown (Source)</option>
<option value="preview">Preview (Read-only)</option>
</select>
</SettingRow>
</SettingSection>
);
// Handlers for post categories management
const handleAddCategory = () => {
const trimmed = newCategoryInput.trim().toLowerCase();
if (trimmed && !postCategories.includes(trimmed)) {
const updated = [...postCategories, trimmed];
setPostCategories(updated);
localStorage.setItem('bds-categories', JSON.stringify(updated));
setNewCategoryInput('');
showToast.success(`Category "${trimmed}" added`);
} else if (postCategories.includes(trimmed)) {
showToast.error('Category already exists');
}
};
const handleRemoveCategory = (categoryToRemove: string) => {
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
return;
}
if (postCategories.length <= 1) {
showToast.error('Must have at least one category');
return;
}
const updated = postCategories.filter(c => c !== categoryToRemove);
setPostCategories(updated);
localStorage.setItem('bds-categories', JSON.stringify(updated));
showToast.success(`Category "${categoryToRemove}" removed`);
};
const handleResetCategories = () => {
setPostCategories(DEFAULT_POST_CATEGORIES);
localStorage.setItem('bds-categories', JSON.stringify(DEFAULT_POST_CATEGORIES));
showToast.success('Categories reset to defaults');
};
const renderContentSettings = () => (
<SettingSection
id="settings-section-content"
title="Post Categories"
description="Manage the available categories for blog posts. Each post can have one category that determines its display template."
hidden={!sectionHasMatches(contentKeywords)}
>
<div className="categories-list">
{postCategories.map((cat) => {
const isProtected = PROTECTED_CATEGORIES.includes(cat);
return (
<div key={cat} className="category-item">
<span className="category-name">{cat}{isProtected && ' (standard)'}</span>
{!isProtected && (
<button
className="category-remove"
onClick={() => handleRemoveCategory(cat)}
title={`Remove "${cat}" category`}
>
</button>
)}
</div>
);
})}
</div>
<div className="category-add-form">
<input
type="text"
placeholder="New category name..."
value={newCategoryInput}
onChange={(e) => setNewCategoryInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCategory();
}
}}
/>
<button className="primary" onClick={handleAddCategory}>
Add Category
</button>
</div>
<div className="setting-actions">
<button className="secondary" onClick={handleResetCategories}>
Reset to Defaults
</button>
</div>
</SettingSection>
);
const renderSyncSettings = () => (
<>
<SettingSection
id="settings-section-sync"
title="File Sync — Dropbox"
description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access."
hidden={!sectionHasMatches(syncKeywords)}
>
<SettingRow
id="dropbox-token"
label="Access Token"
description="Your Dropbox API access token. Generate one from the Dropbox App Console."
>
<div className="setting-input-group">
<input
id="dropbox-token"
type={showSecrets ? 'text' : 'password'}
placeholder="Your Dropbox access token"
value={credentials.dropboxAccessToken}
onChange={(e) => setCredentials({ ...credentials, dropboxAccessToken: e.target.value })}
/>
<button
className="setting-toggle-visibility"
onClick={() => setShowSecrets(!showSecrets)}
title={showSecrets ? 'Hide secrets' : 'Show secrets'}
>
{showSecrets ? '🔒' : '👁'}
</button>
</div>
</SettingRow>
<SettingRow
id="dropbox-appkey"
label="App Key"
description="The App Key from your Dropbox developer application."
>
<input
id="dropbox-appkey"
type="text"
placeholder="Your Dropbox App Key"
value={credentials.dropboxAppKey}
onChange={(e) => setCredentials({ ...credentials, dropboxAppKey: e.target.value })}
/>
</SettingRow>
<SettingRow
id="dropbox-path"
label="Remote Path"
description="The folder path in Dropbox where blog files will be synced. Default: /blog"
>
<input
id="dropbox-path"
type="text"
placeholder="/blog"
value={credentials.dropboxRemotePath}
onChange={(e) => setCredentials({ ...credentials, dropboxRemotePath: e.target.value })}
/>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSaveDropbox}>
{dropboxConfigured ? 'Update Configuration' : 'Enable Dropbox Sync'}
</button>
<button className="secondary" onClick={handleTestDropboxConnection}>
Test Connection
</button>
{dropboxConfigured && (
<button className="secondary" onClick={handleDropboxSync}>
Sync Now
</button>
)}
<button className="secondary danger" onClick={() => handleClearCredentials('dropbox')}>
Clear
</button>
</div>
{dropboxConfigured && (
<div className="setting-status success">
<span className="status-icon"></span>
<span>
Dropbox sync is configured
{dropboxLastSync && (
<span className="status-detail"> · Last sync: {new Date(dropboxLastSync).toLocaleString()}</span>
)}
</span>
</div>
)}
</SettingSection>
</>
);
const renderPublishingSettings = () => (
<>
<SettingSection
id="settings-section-publishing"
title="FTP Publishing"
description="Configure FTP credentials for publishing your blog to a web server."
hidden={!sectionHasMatches(publishingKeywords)}
>
<SettingRow
id="ftp-host"
label="Host"
description="The FTP server hostname or IP address."
>
<input
id="ftp-host"
type="text"
placeholder="ftp.example.com"
value={credentials.ftpHost}
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
/>
</SettingRow>
<SettingRow
id="ftp-user"
label="Username"
description="Your FTP account username."
>
<input
id="ftp-user"
type="text"
placeholder="ftp-user"
value={credentials.ftpUser}
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
/>
</SettingRow>
<SettingRow
id="ftp-password"
label="Password"
description="Your FTP account password."
>
<input
id="ftp-password"
type={showSecrets ? 'text' : 'password'}
placeholder="Password"
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSavePublishing}>Save</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>Clear</button>
</div>
</SettingSection>
<SettingSection
title="SSH Publishing"
description="Configure SSH credentials for secure deployment to your server."
hidden={!sectionHasMatches(publishingKeywords)}
>
<SettingRow
id="ssh-host"
label="Host"
description="The SSH server hostname or IP address."
>
<input
id="ssh-host"
type="text"
placeholder="server.example.com"
value={credentials.sshHost}
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
/>
</SettingRow>
<SettingRow
id="ssh-user"
label="Username"
description="Your SSH account username."
>
<input
id="ssh-user"
type="text"
placeholder="ssh-user"
value={credentials.sshUser}
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
/>
</SettingRow>
<SettingRow
id="ssh-keypath"
label="SSH Key Path"
description="Path to your SSH private key file."
>
<input
id="ssh-keypath"
type="text"
placeholder="~/.ssh/id_rsa"
value={credentials.sshKeyPath}
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
/>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSavePublishing}>Save</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>Clear</button>
</div>
</SettingSection>
</>
);
const renderDataSettings = () => (
<>
<SettingSection
id="settings-section-data"
title="Database Maintenance"
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
hidden={!sectionHasMatches(dataKeywords)}
>
<SettingRow
id="rebuild-posts"
label="Rebuild Posts Database"
description="Re-scan all post markdown files and rebuild the database index."
>
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding posts database...');
try {
await window.electronAPI?.posts.rebuildFromFiles();
const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 });
if (postsResult) {
useAppStore.getState().setPosts(postsResult.items, postsResult.hasMore, postsResult.total);
}
showToast.dismiss();
showToast.success('Posts database rebuilt');
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild posts database');
}
}}
>
Rebuild Posts
</button>
</SettingRow>
<SettingRow
id="rebuild-media"
label="Rebuild Media Database"
description="Re-scan all media files and sidecar metadata. Regenerates missing entries."
>
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding media database...');
try {
await window.electronAPI?.media.rebuildFromFiles();
const media = await window.electronAPI?.media.getAll();
if (media) {
useAppStore.getState().setMedia(media as any[]);
}
showToast.dismiss();
showToast.success('Media database rebuilt');
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild media database');
}
}}
>
Rebuild Media
</button>
</SettingRow>
<SettingRow
id="rebuild-links"
label="Rebuild Post Links"
description="Re-scan all posts and rebuild the internal link graph between posts."
>
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding post links...');
try {
await window.electronAPI?.posts.rebuildLinks();
showToast.dismiss();
showToast.success('Post links rebuilt');
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild post links');
}
}}
>
Rebuild Links
</button>
</SettingRow>
</SettingSection>
<SettingSection
title="File System"
description="Access project data files and directories."
hidden={!sectionHasMatches(dataKeywords)}
>
<SettingRow
id="open-data"
label="Open Data Folder"
description="Open the project data folder containing posts, media, and database files."
>
<button
className="secondary"
onClick={async () => {
const paths = await window.electronAPI?.app.getDataPaths();
if (paths) {
window.electronAPI?.app.openFolder(paths.posts);
}
}}
>
Open Folder
</button>
</SettingRow>
</SettingSection>
</>
);
// Check if any results match the search
const hasAnyMatches = !searchQuery ||
sectionHasMatches(projectKeywords) ||
sectionHasMatches(editorKeywords) ||
sectionHasMatches(contentKeywords) ||
sectionHasMatches(syncKeywords) ||
sectionHasMatches(publishingKeywords) ||
sectionHasMatches(dataKeywords);
return (
<div className="settings-view">
{/* Header with search */}
<div className="settings-header">
<h2>Settings</h2>
<div className="settings-search">
<span className="settings-search-icon"><SearchIcon /></span>
<input
type="text"
placeholder="Search settings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
className="settings-search-clear"
onClick={() => setSearchQuery('')}
>
</button>
)}
</div>
</div>
{/* Settings content - all sections in scrollable list */}
<div className="settings-content" ref={contentRef}>
{hasAnyMatches ? (
<>
{renderProjectSettings()}
{renderEditorSettings()}
{renderContentSettings()}
{renderSyncSettings()}
{renderPublishingSettings()}
{renderDataSettings()}
</>
) : (
<div className="settings-no-results">
<p>No settings found matching "{searchQuery}"</p>
<button onClick={() => setSearchQuery('')}>Clear search</button>
</div>
)}
</div>
</div>
);
};
export default SettingsView;