feat: settings panel
This commit is contained in:
@@ -6,6 +6,7 @@ import { WysiwygEditor } from '../WysiwygEditor';
|
||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
import { PostLinks } from '../PostLinks';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import './Editor.css';
|
||||
|
||||
// Simple markdown to HTML converter for preview
|
||||
@@ -758,6 +759,15 @@ export const Editor: React.FC = () => {
|
||||
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
||||
);
|
||||
|
||||
if (activeView === 'settings') {
|
||||
return (
|
||||
<>
|
||||
<SettingsView />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeView === 'posts' && selectedPostId) {
|
||||
const post = posts.find(p => p.id === selectedPostId);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -579,23 +579,8 @@ const MediaList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPanel: React.FC = () => {
|
||||
const SettingsNav: React.FC = () => {
|
||||
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 (
|
||||
<div className="sidebar-content settings-panel">
|
||||
@@ -605,61 +590,27 @@ const SettingsPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h3>Cloud Sync (Turso/LibSQL)</h3>
|
||||
<div className="settings-field">
|
||||
<label>Turso Database URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="libsql://your-db.turso.io"
|
||||
value={tursoUrl}
|
||||
onChange={(e) => setTursoUrl(e.target.value)}
|
||||
/>
|
||||
<div className="settings-nav-list">
|
||||
<div className="settings-nav-entry">
|
||||
<span className="settings-nav-entry-icon">📝</span>
|
||||
<span>Editor</span>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label>Auth Token</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Your auth token"
|
||||
value={tursoToken}
|
||||
onChange={(e) => setTursoToken(e.target.value)}
|
||||
/>
|
||||
<div className="settings-nav-entry">
|
||||
<span className="settings-nav-entry-icon">🔄</span>
|
||||
<span>Sync</span>
|
||||
{syncConfigured && <span className="settings-nav-badge">✓</span>}
|
||||
</div>
|
||||
<div className="settings-nav-entry">
|
||||
<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>
|
||||
<button onClick={handleSaveSync}>
|
||||
{syncConfigured ? 'Update Sync Settings' : 'Enable Sync'}
|
||||
</button>
|
||||
|
||||
{syncConfigured && (
|
||||
<p className="settings-status status-published">✓ Sync is configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<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>
|
||||
<p className="settings-hint">Configure settings in the main editor area.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -675,7 +626,7 @@ export const Sidebar: React.FC = () => {
|
||||
<div className="sidebar">
|
||||
{activeView === 'posts' && <PostsList />}
|
||||
{activeView === 'media' && <MediaList />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
{activeView === 'settings' && <SettingsNav />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,5 +10,6 @@ export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
||||
export { TaskPopup } from './TaskPopup';
|
||||
export { ResizablePanel } from './ResizablePanel';
|
||||
export { CredentialsPanel } from './CredentialsPanel';
|
||||
export { SettingsView } from './SettingsView';
|
||||
export { PostLinks } from './PostLinks';
|
||||
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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||
@@ -142,6 +163,19 @@ export interface ElectronAPI {
|
||||
cancel: (taskId: string) => Promise<boolean>;
|
||||
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: {
|
||||
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
||||
openFolder: (folderPath: string) => Promise<string>;
|
||||
|
||||
Reference in New Issue
Block a user