feat: settings panel
This commit is contained in:
@@ -8,6 +8,10 @@ sync to a cloud system for syncing data and also rendering the full blog.
|
|||||||
|
|
||||||
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
|
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
|
||||||
|
|
||||||
|
The main area of the window must be a tabbled view, where multiple tabs can be open at the same time and are
|
||||||
|
retained over program runs. The tabs can be different tabs like media file tabs, post tabs for multiple
|
||||||
|
posts and setting tabs or whatever will come later.
|
||||||
|
|
||||||
We need a good way to handle the syncing of the non-metadata components (posts and media files), because that
|
We need a good way to handle the syncing of the non-metadata components (posts and media files), because that
|
||||||
is not part of the database sync. One way could be using something like dropbox in the background, so that
|
is not part of the database sync. One way could be using something like dropbox in the background, so that
|
||||||
the posts/ and media/ folders are automatically synced to some area in dropbox and transported that way.
|
the posts/ and media/ folders are automatically synced to some area in dropbox and transported that way.
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ import * as fs from 'fs/promises';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { Dropbox } from 'dropbox';
|
import { Dropbox } from 'dropbox';
|
||||||
import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
|
import type { FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
type ChokidarWatchFn = (paths: string | readonly string[], options?: Record<string, unknown>) => FSWatcher;
|
||||||
|
|
||||||
|
let _chokidarWatch: ChokidarWatchFn | null = null;
|
||||||
|
async function getChokidarWatch(): Promise<ChokidarWatchFn> {
|
||||||
|
if (!_chokidarWatch) {
|
||||||
|
const chokidar = await import('chokidar');
|
||||||
|
_chokidarWatch = chokidar.watch as unknown as ChokidarWatchFn;
|
||||||
|
}
|
||||||
|
return _chokidarWatch;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Types & Interfaces
|
// Types & Interfaces
|
||||||
@@ -83,7 +94,7 @@ export class DropboxSyncEngine extends EventEmitter {
|
|||||||
private status: DropboxSyncStatus = 'idle';
|
private status: DropboxSyncStatus = 'idle';
|
||||||
private config: DropboxSyncConfig | null = null;
|
private config: DropboxSyncConfig | null = null;
|
||||||
private dropboxClient: Dropbox;
|
private dropboxClient: Dropbox;
|
||||||
private watchFn: typeof chokidarWatch;
|
private watchFn: ChokidarWatchFn | null;
|
||||||
private watcher: FSWatcher | null = null;
|
private watcher: FSWatcher | null = null;
|
||||||
private pollIntervalId: NodeJS.Timeout | null = null;
|
private pollIntervalId: NodeJS.Timeout | null = null;
|
||||||
private pendingConflicts: Map<string, DropboxConflict> = new Map();
|
private pendingConflicts: Map<string, DropboxConflict> = new Map();
|
||||||
@@ -98,10 +109,17 @@ export class DropboxSyncEngine extends EventEmitter {
|
|||||||
// Track files we wrote ourselves (to ignore watcher events)
|
// Track files we wrote ourselves (to ignore watcher events)
|
||||||
private recentDownloads: Set<string> = new Set();
|
private recentDownloads: Set<string> = new Set();
|
||||||
|
|
||||||
constructor(dropboxClient?: Dropbox, watchFn?: typeof chokidarWatch) {
|
constructor(dropboxClient?: Dropbox, watchFn?: ChokidarWatchFn) {
|
||||||
super();
|
super();
|
||||||
this.dropboxClient = dropboxClient || new Dropbox({});
|
this.dropboxClient = dropboxClient || new Dropbox({});
|
||||||
this.watchFn = watchFn || chokidarWatch;
|
this.watchFn = watchFn || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWatchFn(): Promise<ChokidarWatchFn> {
|
||||||
|
if (!this.watchFn) {
|
||||||
|
this.watchFn = await getChokidarWatch();
|
||||||
|
}
|
||||||
|
return this.watchFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -681,7 +699,7 @@ export class DropboxSyncEngine extends EventEmitter {
|
|||||||
// Local File Watching
|
// Local File Watching
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
startWatching(): void {
|
async startWatching(): Promise<void> {
|
||||||
if (!this.config) return;
|
if (!this.config) return;
|
||||||
|
|
||||||
const watchPaths = [
|
const watchPaths = [
|
||||||
@@ -689,7 +707,8 @@ export class DropboxSyncEngine extends EventEmitter {
|
|||||||
this.config.localMediaDir,
|
this.config.localMediaDir,
|
||||||
];
|
];
|
||||||
|
|
||||||
this.watcher = this.watchFn(watchPaths, {
|
const watchFn = await this.getWatchFn();
|
||||||
|
this.watcher = watchFn(watchPaths, {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { WysiwygEditor } from '../WysiwygEditor';
|
|||||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||||
import { PostLinks } from '../PostLinks';
|
import { PostLinks } from '../PostLinks';
|
||||||
import { ErrorModal } from '../ErrorModal';
|
import { ErrorModal } from '../ErrorModal';
|
||||||
|
import { SettingsView } from '../SettingsView';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
// Simple markdown to HTML converter for preview
|
// Simple markdown to HTML converter for preview
|
||||||
@@ -758,6 +759,15 @@ export const Editor: React.FC = () => {
|
|||||||
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (activeView === 'settings') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsView />
|
||||||
|
{renderErrorModal()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeView === 'posts' && selectedPostId) {
|
if (activeView === 'posts' && selectedPostId) {
|
||||||
const post = posts.find(p => p.id === selectedPostId);
|
const post = posts.find(p => p.id === selectedPostId);
|
||||||
if (post) {
|
if (post) {
|
||||||
|
|||||||
381
src/renderer/components/SettingsView/SettingsView.css
Normal file
381
src/renderer/components/SettingsView/SettingsView.css
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
.settings-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header with search */
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 28px 6px 30px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search input:focus {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-clear:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body layout */
|
||||||
|
.settings-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category navigation */
|
||||||
|
.settings-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 180px;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-right: 1px solid var(--vscode-panel-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active {
|
||||||
|
background-color: var(--vscode-list-activeSelectionBackground);
|
||||||
|
border-left-color: var(--vscode-focusBorder);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings content area */
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 24px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setting section */
|
||||||
|
.setting-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-description {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual setting row */
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control input[type="text"],
|
||||||
|
.setting-control input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control input:focus {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: var(--vscode-dropdown-background);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
color: var(--vscode-dropdown-foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control select:focus {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input group with button */
|
||||||
|
.setting-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle-visibility {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle-visibility:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.setting-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.primary {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.primary:hover {
|
||||||
|
background-color: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.secondary {
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.secondary:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.danger {
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions button.danger:hover {
|
||||||
|
background-color: rgba(244, 135, 113, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.setting-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-status.success {
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
background-color: rgba(115, 201, 145, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-status .status-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-status .status-detail {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setting row button styling */
|
||||||
|
.setting-row .setting-control button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row .setting-control button:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - narrow sidebar */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.settings-nav {
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-icon {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
722
src/renderer/components/SettingsView/SettingsView.tsx
Normal file
722
src/renderer/components/SettingsView/SettingsView.tsx
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import './SettingsView.css';
|
||||||
|
|
||||||
|
// Settings categories matching VS Code style
|
||||||
|
type SettingsCategory = 'editor' | 'sync' | 'publishing' | 'data';
|
||||||
|
|
||||||
|
interface Credentials {
|
||||||
|
// Turso Cloud Sync
|
||||||
|
tursoUrl: string;
|
||||||
|
tursoToken: string;
|
||||||
|
// 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 = {
|
||||||
|
tursoUrl: '',
|
||||||
|
tursoToken: '',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Category definitions
|
||||||
|
const categories: { id: SettingsCategory; label: string; icon: string }[] = [
|
||||||
|
{ id: 'editor', label: 'Editor', icon: '📝' },
|
||||||
|
{ id: 'sync', label: 'Sync', icon: '🔄' },
|
||||||
|
{ id: 'publishing', label: 'Publishing', icon: '🚀' },
|
||||||
|
{ id: 'data', label: 'Data Management', icon: '🗄️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const SettingSection: React.FC<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ title, description, children }) => (
|
||||||
|
<div className="setting-section">
|
||||||
|
<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, syncConfigured } = useAppStore();
|
||||||
|
const [activeCategory, setActiveCategory] = useState<SettingsCategory>('editor');
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Load saved credentials
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCredentials = async () => {
|
||||||
|
try {
|
||||||
|
const savedCreds = localStorage.getItem('bds-credentials');
|
||||||
|
if (savedCreds) {
|
||||||
|
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 credentials:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCredentials();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save credentials and configure backends
|
||||||
|
const handleSaveTurso = async () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||||
|
|
||||||
|
if (credentials.tursoUrl && credentials.tursoToken) {
|
||||||
|
await window.electronAPI?.sync.configure({
|
||||||
|
tursoUrl: credentials.tursoUrl,
|
||||||
|
tursoAuthToken: credentials.tursoToken,
|
||||||
|
autoSync: true,
|
||||||
|
syncInterval: 5,
|
||||||
|
});
|
||||||
|
showToast.success('Cloud sync configured');
|
||||||
|
} else {
|
||||||
|
showToast.success('Credentials saved');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Turso credentials:', error);
|
||||||
|
showToast.error('Failed to configure cloud sync');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'turso' | 'dropbox' | 'ftp' | 'ssh') => {
|
||||||
|
const newCreds = { ...credentials };
|
||||||
|
switch (type) {
|
||||||
|
case 'turso':
|
||||||
|
newCreds.tursoUrl = '';
|
||||||
|
newCreds.tursoToken = '';
|
||||||
|
break;
|
||||||
|
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 handleTestConnection = async (type: 'turso' | 'dropbox') => {
|
||||||
|
showToast.loading(`Testing ${type} connection...`);
|
||||||
|
try {
|
||||||
|
if (type === 'turso') {
|
||||||
|
// Simulate connection test
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
if (credentials.tursoUrl && credentials.tursoToken) {
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.success('Cloud sync connection successful');
|
||||||
|
} else {
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.error('Missing credentials');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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(`${type} connection failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter categories if searching
|
||||||
|
const filteredCategories = searchQuery
|
||||||
|
? categories.filter(c => c.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
const renderEditorSettings = () => (
|
||||||
|
<>
|
||||||
|
<SettingSection
|
||||||
|
title="Editor"
|
||||||
|
description="Configure the blog post editor behavior and appearance."
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSyncSettings = () => (
|
||||||
|
<>
|
||||||
|
<SettingSection
|
||||||
|
title="Cloud Sync — Turso/LibSQL"
|
||||||
|
description="Sync post and media metadata to a Turso cloud database for backup and multi-device access."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
id="turso-url"
|
||||||
|
label="Database URL"
|
||||||
|
description="The Turso/LibSQL database URL. Example: libsql://your-database.turso.io"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="turso-url"
|
||||||
|
type="text"
|
||||||
|
placeholder="libsql://your-database.turso.io"
|
||||||
|
value={credentials.tursoUrl}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
id="turso-token"
|
||||||
|
label="Auth Token"
|
||||||
|
description="Your Turso database authentication token."
|
||||||
|
>
|
||||||
|
<div className="setting-input-group">
|
||||||
|
<input
|
||||||
|
id="turso-token"
|
||||||
|
type={showSecrets ? 'text' : 'password'}
|
||||||
|
placeholder="Your authentication token"
|
||||||
|
value={credentials.tursoToken}
|
||||||
|
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="setting-toggle-visibility"
|
||||||
|
onClick={() => setShowSecrets(!showSecrets)}
|
||||||
|
title={showSecrets ? 'Hide secrets' : 'Show secrets'}
|
||||||
|
>
|
||||||
|
{showSecrets ? '🔒' : '👁'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<div className="setting-actions">
|
||||||
|
<button className="primary" onClick={handleSaveTurso}>
|
||||||
|
{syncConfigured ? 'Update Configuration' : 'Enable Cloud Sync'}
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={() => handleTestConnection('turso')}>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button className="secondary danger" onClick={() => handleClearCredentials('turso')}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncConfigured && (
|
||||||
|
<div className="setting-status success">
|
||||||
|
<span className="status-icon">✓</span>
|
||||||
|
<span>Cloud sync is configured and active</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection
|
||||||
|
title="File Sync — Dropbox"
|
||||||
|
description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access."
|
||||||
|
>
|
||||||
|
<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={() => handleTestConnection('dropbox')}>
|
||||||
|
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
|
||||||
|
title="FTP Publishing"
|
||||||
|
description="Configure FTP credentials for publishing your blog to a web server."
|
||||||
|
>
|
||||||
|
<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."
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
title="Database Maintenance"
|
||||||
|
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
|
||||||
|
>
|
||||||
|
<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 posts = await window.electronAPI?.posts.getAll();
|
||||||
|
if (posts) {
|
||||||
|
useAppStore.getState().setPosts(posts as any[]);
|
||||||
|
}
|
||||||
|
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."
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (searchQuery) {
|
||||||
|
// Show all matching settings when searching
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderEditorSettings()}
|
||||||
|
{renderSyncSettings()}
|
||||||
|
{renderPublishingSettings()}
|
||||||
|
{renderDataSettings()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (activeCategory) {
|
||||||
|
case 'editor':
|
||||||
|
return renderEditorSettings();
|
||||||
|
case 'sync':
|
||||||
|
return renderSyncSettings();
|
||||||
|
case 'publishing':
|
||||||
|
return renderPublishingSettings();
|
||||||
|
case 'data':
|
||||||
|
return renderDataSettings();
|
||||||
|
default:
|
||||||
|
return renderEditorSettings();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="settings-body">
|
||||||
|
{/* Category navigation sidebar */}
|
||||||
|
<nav className="settings-nav">
|
||||||
|
{filteredCategories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={`settings-nav-item ${activeCategory === cat.id && !searchQuery ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveCategory(cat.id);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="settings-nav-icon">{cat.icon}</span>
|
||||||
|
<span className="settings-nav-label">{cat.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Settings content */}
|
||||||
|
<div className="settings-content">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsView;
|
||||||
1
src/renderer/components/SettingsView/index.ts
Normal file
1
src/renderer/components/SettingsView/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SettingsView } from './SettingsView';
|
||||||
@@ -201,6 +201,42 @@
|
|||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-entry-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 8px 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -579,23 +579,8 @@ const MediaList: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsPanel: React.FC = () => {
|
const SettingsNav: React.FC = () => {
|
||||||
const { syncConfigured } = useAppStore();
|
const { syncConfigured } = useAppStore();
|
||||||
const [tursoUrl, setTursoUrl] = React.useState('');
|
|
||||||
const [tursoToken, setTursoToken] = React.useState('');
|
|
||||||
|
|
||||||
const handleSaveSync = async () => {
|
|
||||||
try {
|
|
||||||
await window.electronAPI?.sync.configure({
|
|
||||||
tursoUrl,
|
|
||||||
tursoAuthToken: tursoToken,
|
|
||||||
autoSync: true,
|
|
||||||
syncInterval: 5,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to configure sync:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-content settings-panel">
|
<div className="sidebar-content settings-panel">
|
||||||
@@ -605,61 +590,27 @@ const SettingsPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-nav-list">
|
||||||
<h3>Cloud Sync (Turso/LibSQL)</h3>
|
<div className="settings-nav-entry">
|
||||||
<div className="settings-field">
|
<span className="settings-nav-entry-icon">📝</span>
|
||||||
<label>Turso Database URL</label>
|
<span>Editor</span>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="libsql://your-db.turso.io"
|
|
||||||
value={tursoUrl}
|
|
||||||
onChange={(e) => setTursoUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-field">
|
<div className="settings-nav-entry">
|
||||||
<label>Auth Token</label>
|
<span className="settings-nav-entry-icon">🔄</span>
|
||||||
<input
|
<span>Sync</span>
|
||||||
type="password"
|
{syncConfigured && <span className="settings-nav-badge">✓</span>}
|
||||||
placeholder="Your auth token"
|
</div>
|
||||||
value={tursoToken}
|
<div className="settings-nav-entry">
|
||||||
onChange={(e) => setTursoToken(e.target.value)}
|
<span className="settings-nav-entry-icon">🚀</span>
|
||||||
/>
|
<span>Publishing</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-nav-entry">
|
||||||
|
<span className="settings-nav-entry-icon">🗄️</span>
|
||||||
|
<span>Data</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleSaveSync}>
|
|
||||||
{syncConfigured ? 'Update Sync Settings' : 'Enable Sync'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{syncConfigured && (
|
|
||||||
<p className="settings-status status-published">✓ Sync is configured</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<p className="settings-hint">Configure settings in the main editor area.</p>
|
||||||
<h3>Data Management</h3>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => window.electronAPI?.posts.rebuildFromFiles()}
|
|
||||||
>
|
|
||||||
Rebuild Posts Database
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => window.electronAPI?.media.rebuildFromFiles()}
|
|
||||||
>
|
|
||||||
Rebuild Media Database
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={async () => {
|
|
||||||
const paths = await window.electronAPI?.app.getDataPaths();
|
|
||||||
if (paths) {
|
|
||||||
window.electronAPI?.app.openFolder(paths.posts);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Data Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -675,7 +626,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
{activeView === 'posts' && <PostsList />}
|
{activeView === 'posts' && <PostsList />}
|
||||||
{activeView === 'media' && <MediaList />}
|
{activeView === 'media' && <MediaList />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsNav />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
|||||||
export { TaskPopup } from './TaskPopup';
|
export { TaskPopup } from './TaskPopup';
|
||||||
export { ResizablePanel } from './ResizablePanel';
|
export { ResizablePanel } from './ResizablePanel';
|
||||||
export { CredentialsPanel } from './CredentialsPanel';
|
export { CredentialsPanel } from './CredentialsPanel';
|
||||||
|
export { SettingsView } from './SettingsView';
|
||||||
export { PostLinks } from './PostLinks';
|
export { PostLinks } from './PostLinks';
|
||||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||||
|
|||||||
34
src/renderer/types/electron.d.ts
vendored
34
src/renderer/types/electron.d.ts
vendored
@@ -85,6 +85,27 @@ export interface SyncResult {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DropboxConfig {
|
||||||
|
accessToken: string;
|
||||||
|
appKey: string;
|
||||||
|
remotePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropboxSyncResult {
|
||||||
|
uploaded: number;
|
||||||
|
downloaded: number;
|
||||||
|
conflicts: number;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropboxConflict {
|
||||||
|
id: string;
|
||||||
|
localPath: string;
|
||||||
|
remotePath: string;
|
||||||
|
localModified: string;
|
||||||
|
remoteModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
projects: {
|
projects: {
|
||||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||||
@@ -142,6 +163,19 @@ export interface ElectronAPI {
|
|||||||
cancel: (taskId: string) => Promise<boolean>;
|
cancel: (taskId: string) => Promise<boolean>;
|
||||||
clearCompleted: () => Promise<void>;
|
clearCompleted: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
dropbox: {
|
||||||
|
configure: (config: DropboxConfig) => Promise<void>;
|
||||||
|
isConfigured: () => Promise<boolean>;
|
||||||
|
getStatus: () => Promise<string>;
|
||||||
|
syncAll: () => Promise<DropboxSyncResult>;
|
||||||
|
startWatching: () => Promise<void>;
|
||||||
|
stopWatching: () => Promise<void>;
|
||||||
|
startPolling: () => Promise<void>;
|
||||||
|
stopPolling: () => Promise<void>;
|
||||||
|
getConflicts: () => Promise<DropboxConflict[]>;
|
||||||
|
resolveConflict: (conflictId: string, resolution: 'local-wins' | 'remote-wins') => Promise<void>;
|
||||||
|
getLastSyncTime: () => Promise<string | null>;
|
||||||
|
};
|
||||||
app: {
|
app: {
|
||||||
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
||||||
openFolder: (folderPath: string) => Promise<string>;
|
openFolder: (folderPath: string) => Promise<string>;
|
||||||
|
|||||||
@@ -861,8 +861,8 @@ describe('DropboxSyncEngine', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should start watching local directories', () => {
|
it('should start watching local directories', async () => {
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
|
|
||||||
expect(mockChokidarWatch).toHaveBeenCalledWith(
|
expect(mockChokidarWatch).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -876,45 +876,45 @@ describe('DropboxSyncEngine', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set status to watching when watching starts', () => {
|
it('should set status to watching when watching starts', async () => {
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
expect(engine.getStatus()).toBe('watching');
|
expect(engine.getStatus()).toBe('watching');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop watching when requested', () => {
|
it('should stop watching when requested', async () => {
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
engine.stopWatching();
|
engine.stopWatching();
|
||||||
|
|
||||||
expect(mockWatcher.close).toHaveBeenCalled();
|
expect(mockWatcher.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set status to idle when watching stops', () => {
|
it('should set status to idle when watching stops', async () => {
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
engine.stopWatching();
|
engine.stopWatching();
|
||||||
expect(engine.getStatus()).toBe('idle');
|
expect(engine.getStatus()).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit watchStarted event', () => {
|
it('should emit watchStarted event', async () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
engine.on('watchStarted', handler);
|
engine.on('watchStarted', handler);
|
||||||
|
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalled();
|
expect(handler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit watchStopped event', () => {
|
it('should emit watchStopped event', async () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
engine.on('watchStopped', handler);
|
engine.on('watchStopped', handler);
|
||||||
|
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
engine.stopWatching();
|
engine.stopWatching();
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalled();
|
expect(handler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should register add, change, and unlink handlers', () => {
|
it('should register add, change, and unlink handlers', async () => {
|
||||||
engine.startWatching();
|
await engine.startWatching();
|
||||||
|
|
||||||
const onCalls = mockWatcher.on.mock.calls.map((call: any[]) => call[0]);
|
const onCalls = mockWatcher.on.mock.calls.map((call: any[]) => call[0]);
|
||||||
expect(onCalls).toContain('add');
|
expect(onCalls).toContain('add');
|
||||||
|
|||||||
221
tests/renderer/components/SettingsView.test.ts
Normal file
221
tests/renderer/components/SettingsView.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Tests for SettingsView component behavior
|
||||||
|
* Validates VS Code-style structured preferences with Dropbox sync settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store/appStore';
|
||||||
|
|
||||||
|
// Direct store access
|
||||||
|
const getStore = () => useAppStore.getState();
|
||||||
|
const setState = useAppStore.setState;
|
||||||
|
|
||||||
|
describe('SettingsView Behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setState({
|
||||||
|
syncConfigured: false,
|
||||||
|
syncStatus: 'idle',
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Categories', () => {
|
||||||
|
it('should have sync settings as a category in the store', () => {
|
||||||
|
// The activeView: 'settings' should be a valid view
|
||||||
|
getStore().setActiveView('settings');
|
||||||
|
expect(getStore().activeView).toBe('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist preferred editor mode', () => {
|
||||||
|
getStore().setPreferredEditorMode('markdown');
|
||||||
|
expect(getStore().preferredEditorMode).toBe('markdown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Turso Cloud Sync Configuration', () => {
|
||||||
|
it('should call sync.configure with Turso credentials', async () => {
|
||||||
|
const mockConfigure = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI.sync.configure = mockConfigure;
|
||||||
|
|
||||||
|
await window.electronAPI?.sync.configure({
|
||||||
|
tursoUrl: 'libsql://test.turso.io',
|
||||||
|
tursoAuthToken: 'test-token',
|
||||||
|
autoSync: true,
|
||||||
|
syncInterval: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockConfigure).toHaveBeenCalledWith({
|
||||||
|
tursoUrl: 'libsql://test.turso.io',
|
||||||
|
tursoAuthToken: 'test-token',
|
||||||
|
autoSync: true,
|
||||||
|
syncInterval: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update syncConfigured status after successful configure', () => {
|
||||||
|
getStore().setSyncConfigured(true);
|
||||||
|
expect(getStore().syncConfigured).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dropbox Sync Configuration', () => {
|
||||||
|
it('should call dropbox.configure with Dropbox credentials', async () => {
|
||||||
|
const mockConfigure = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI.dropbox = {
|
||||||
|
configure: mockConfigure,
|
||||||
|
isConfigured: vi.fn(),
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
syncAll: vi.fn(),
|
||||||
|
startWatching: vi.fn(),
|
||||||
|
stopWatching: vi.fn(),
|
||||||
|
startPolling: vi.fn(),
|
||||||
|
stopPolling: vi.fn(),
|
||||||
|
getConflicts: vi.fn(),
|
||||||
|
resolveConflict: vi.fn(),
|
||||||
|
getLastSyncTime: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.electronAPI?.dropbox?.configure({
|
||||||
|
accessToken: 'dbx-test-token',
|
||||||
|
appKey: 'test-app-key',
|
||||||
|
remotePath: '/blog',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockConfigure).toHaveBeenCalledWith({
|
||||||
|
accessToken: 'dbx-test-token',
|
||||||
|
appKey: 'test-app-key',
|
||||||
|
remotePath: '/blog',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check dropbox configuration status', async () => {
|
||||||
|
const mockIsConfigured = vi.fn().mockResolvedValue(true);
|
||||||
|
(window as any).electronAPI.dropbox = {
|
||||||
|
...((window as any).electronAPI.dropbox || {}),
|
||||||
|
isConfigured: mockIsConfigured,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await window.electronAPI?.dropbox?.isConfigured();
|
||||||
|
expect(mockIsConfigured).toHaveBeenCalled();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger Dropbox full sync', async () => {
|
||||||
|
const mockSyncAll = vi.fn().mockResolvedValue({ uploaded: 0, downloaded: 0, conflicts: 0 });
|
||||||
|
(window as any).electronAPI.dropbox = {
|
||||||
|
...((window as any).electronAPI.dropbox || {}),
|
||||||
|
syncAll: mockSyncAll,
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.electronAPI?.dropbox?.syncAll();
|
||||||
|
expect(mockSyncAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get last sync time', async () => {
|
||||||
|
const mockGetLastSyncTime = vi.fn().mockResolvedValue('2026-02-10T12:00:00Z');
|
||||||
|
(window as any).electronAPI.dropbox = {
|
||||||
|
...((window as any).electronAPI.dropbox || {}),
|
||||||
|
getLastSyncTime: mockGetLastSyncTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await window.electronAPI?.dropbox?.getLastSyncTime();
|
||||||
|
expect(result).toBe('2026-02-10T12:00:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Credentials Storage', () => {
|
||||||
|
it('should save credentials to localStorage', () => {
|
||||||
|
const creds = {
|
||||||
|
tursoUrl: 'libsql://test.turso.io',
|
||||||
|
tursoToken: 'test-token',
|
||||||
|
dropboxAccessToken: 'dbx-token',
|
||||||
|
dropboxAppKey: 'dbx-key',
|
||||||
|
dropboxRemotePath: '/blog',
|
||||||
|
};
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(creds));
|
||||||
|
|
||||||
|
const saved = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
expect(saved.tursoUrl).toBe('libsql://test.turso.io');
|
||||||
|
expect(saved.dropboxAccessToken).toBe('dbx-token');
|
||||||
|
expect(saved.dropboxAppKey).toBe('dbx-key');
|
||||||
|
expect(saved.dropboxRemotePath).toBe('/blog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load credentials from localStorage', () => {
|
||||||
|
const creds = {
|
||||||
|
tursoUrl: 'libsql://saved.turso.io',
|
||||||
|
tursoToken: 'saved-token',
|
||||||
|
dropboxAccessToken: 'saved-dbx-token',
|
||||||
|
};
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(creds));
|
||||||
|
|
||||||
|
const loaded = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
expect(loaded.tursoUrl).toBe('libsql://saved.turso.io');
|
||||||
|
expect(loaded.dropboxAccessToken).toBe('saved-dbx-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle clearing sync credentials independently', () => {
|
||||||
|
const creds = {
|
||||||
|
tursoUrl: 'libsql://test.turso.io',
|
||||||
|
tursoToken: 'test-token',
|
||||||
|
dropboxAccessToken: 'dbx-token',
|
||||||
|
dropboxAppKey: 'dbx-key',
|
||||||
|
};
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(creds));
|
||||||
|
|
||||||
|
// Clear only Turso credentials
|
||||||
|
const loaded = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
const cleared = { ...loaded, tursoUrl: '', tursoToken: '' };
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(cleared));
|
||||||
|
|
||||||
|
const result = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
expect(result.tursoUrl).toBe('');
|
||||||
|
expect(result.tursoToken).toBe('');
|
||||||
|
// Dropbox credentials should be untouched
|
||||||
|
expect(result.dropboxAccessToken).toBe('dbx-token');
|
||||||
|
expect(result.dropboxAppKey).toBe('dbx-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle clearing Dropbox credentials independently', () => {
|
||||||
|
const creds = {
|
||||||
|
tursoUrl: 'libsql://test.turso.io',
|
||||||
|
tursoToken: 'test-token',
|
||||||
|
dropboxAccessToken: 'dbx-token',
|
||||||
|
dropboxAppKey: 'dbx-key',
|
||||||
|
dropboxRemotePath: '/blog',
|
||||||
|
};
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(creds));
|
||||||
|
|
||||||
|
// Clear only Dropbox credentials
|
||||||
|
const loaded = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
const cleared = { ...loaded, dropboxAccessToken: '', dropboxAppKey: '', dropboxRemotePath: '' };
|
||||||
|
localStorage.setItem('bds-credentials', JSON.stringify(cleared));
|
||||||
|
|
||||||
|
const result = JSON.parse(localStorage.getItem('bds-credentials') || '{}');
|
||||||
|
// Turso credentials should be untouched
|
||||||
|
expect(result.tursoUrl).toBe('libsql://test.turso.io');
|
||||||
|
expect(result.tursoToken).toBe('test-token');
|
||||||
|
expect(result.dropboxAccessToken).toBe('');
|
||||||
|
expect(result.dropboxAppKey).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data Management Settings', () => {
|
||||||
|
it('should call rebuild posts from files', async () => {
|
||||||
|
const mockRebuild = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI.posts.rebuildFromFiles = mockRebuild;
|
||||||
|
|
||||||
|
await window.electronAPI?.posts.rebuildFromFiles();
|
||||||
|
expect(mockRebuild).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call rebuild media from files', async () => {
|
||||||
|
const mockRebuild = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI.media.rebuildFromFiles = mockRebuild;
|
||||||
|
|
||||||
|
await window.electronAPI?.media.rebuildFromFiles();
|
||||||
|
expect(mockRebuild).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,19 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
getLog: vi.fn(),
|
getLog: vi.fn(),
|
||||||
stopAutoSync: vi.fn(),
|
stopAutoSync: vi.fn(),
|
||||||
},
|
},
|
||||||
|
dropbox: {
|
||||||
|
configure: vi.fn(),
|
||||||
|
isConfigured: vi.fn(),
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
syncAll: vi.fn(),
|
||||||
|
startWatching: vi.fn(),
|
||||||
|
stopWatching: vi.fn(),
|
||||||
|
startPolling: vi.fn(),
|
||||||
|
stopPolling: vi.fn(),
|
||||||
|
getConflicts: vi.fn(),
|
||||||
|
resolveConflict: vi.fn(),
|
||||||
|
getLastSyncTime: vi.fn(),
|
||||||
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
getAll: vi.fn(),
|
getAll: vi.fn(),
|
||||||
getRunning: vi.fn(),
|
getRunning: vi.fn(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
pool: 'forks',
|
pool: 'forks',
|
||||||
poolOptions: {
|
poolOptions: {
|
||||||
forks: {
|
forks: {
|
||||||
|
minForks: 1,
|
||||||
maxForks: Math.max(1, Math.floor(os.cpus().length / 2)),
|
maxForks: Math.max(1, Math.floor(os.cpus().length / 2)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user