feat: proper sidebar and import persistence
This commit is contained in:
@@ -66,8 +66,8 @@ export const ActivityBar: React.FC = () => {
|
||||
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
|
||||
const isChatActive = activeView === 'chat' && sidebarVisible;
|
||||
|
||||
// Check if import tab is currently active
|
||||
const isImportTabActive = tabs.some(t => t.type === 'import' && t.id === activeTabId);
|
||||
// Check if import sidebar is active
|
||||
const isImportActive = activeView === 'import' && sidebarVisible;
|
||||
|
||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
||||
@@ -106,8 +106,14 @@ export const ActivityBar: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
// Open import as a dedicated (non-transient) tab
|
||||
openTab({ type: 'import', id: 'import', isTransient: false });
|
||||
if (activeView === 'import' && sidebarVisible) {
|
||||
toggleSidebar();
|
||||
} else {
|
||||
setActiveView('import');
|
||||
if (!sidebarVisible) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -142,9 +148,9 @@ export const ActivityBar: React.FC = () => {
|
||||
<ChatIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
|
||||
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
|
||||
onClick={handleImportClick}
|
||||
title="Import Analysis"
|
||||
title="Import (click again to toggle sidebar)"
|
||||
>
|
||||
<ImportIcon />
|
||||
</button>
|
||||
|
||||
@@ -1622,10 +1622,10 @@ export const Editor: React.FC = () => {
|
||||
}
|
||||
|
||||
// Show import analysis if import tab is active
|
||||
if (showImport) {
|
||||
if (showImport && activeTabId) {
|
||||
return (
|
||||
<div className="editor">
|
||||
<ImportAnalysisView />
|
||||
<ImportAnalysisView key={activeTabId} definitionId={activeTabId} />
|
||||
{renderErrorModal()}
|
||||
{renderConfirmDeleteModal()}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,28 @@
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-definition-name {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
padding: 2px 6px;
|
||||
margin: -2px -6px;
|
||||
width: calc(100% + 12px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.import-definition-name:hover {
|
||||
border-color: var(--vscode-input-border, #3c3c3c);
|
||||
}
|
||||
|
||||
.import-definition-name:focus {
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
background: var(--vscode-input-background, #1e1e1e);
|
||||
}
|
||||
|
||||
.import-analysis-header p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import './ImportAnalysisView.css';
|
||||
|
||||
interface AnalysisReport {
|
||||
@@ -53,43 +52,107 @@ interface TaxonomyItem {
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export const ImportAnalysisView: React.FC = () => {
|
||||
const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore();
|
||||
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
interface ImportAnalysisViewProps {
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
const report = importAnalysis as AnalysisReport | null;
|
||||
export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definitionId }) => {
|
||||
const [name, setName] = useState('Untitled Import');
|
||||
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
|
||||
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<AnalysisReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingDefinition, setIsLoadingDefinition] = useState(true);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load definition on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoadingDefinition(true);
|
||||
try {
|
||||
const def = await window.electronAPI?.importDefinitions.get(definitionId);
|
||||
if (def) {
|
||||
setName(def.name);
|
||||
if (def.uploadsFolderPath) setUploadsFolder(def.uploadsFolderPath);
|
||||
if (def.wxrFilePath) setWxrFilePath(def.wxrFilePath);
|
||||
if (def.lastAnalysisResult) {
|
||||
const parsed = typeof def.lastAnalysisResult === 'string'
|
||||
? JSON.parse(def.lastAnalysisResult)
|
||||
: def.lastAnalysisResult;
|
||||
setReport(parsed as AnalysisReport);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load import definition:', error);
|
||||
} finally {
|
||||
setIsLoadingDefinition(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [definitionId]);
|
||||
|
||||
const handleNameBlur = useCallback(async () => {
|
||||
const trimmed = name.trim() || 'Untitled Import';
|
||||
setName(trimmed);
|
||||
await window.electronAPI?.importDefinitions.update(definitionId, { name: trimmed });
|
||||
}, [definitionId, name]);
|
||||
|
||||
const handleSelectUploadsFolder = useCallback(async () => {
|
||||
const folder = await window.electronAPI?.import.selectUploadsFolder();
|
||||
if (folder) {
|
||||
setUploadsFolder(folder);
|
||||
await window.electronAPI?.importDefinitions.update(definitionId, { uploadsFolderPath: folder });
|
||||
}
|
||||
}, []);
|
||||
}, [definitionId]);
|
||||
|
||||
const handleSelectAndAnalyze = useCallback(async () => {
|
||||
setImportAnalysisLoading(true);
|
||||
setImportAnalysis(null);
|
||||
setIsLoading(true);
|
||||
setReport(null);
|
||||
try {
|
||||
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined);
|
||||
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined) as AnalysisReport | null;
|
||||
if (result) {
|
||||
setImportAnalysis(result);
|
||||
setReport(result);
|
||||
setWxrFilePath(result.sourceFile);
|
||||
await window.electronAPI?.importDefinitions.update(definitionId, {
|
||||
lastAnalysisResult: JSON.stringify(result),
|
||||
wxrFilePath: result.sourceFile,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import analysis failed:', error);
|
||||
} finally {
|
||||
setImportAnalysisLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]);
|
||||
}, [definitionId, uploadsFolder]);
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
}, []);
|
||||
|
||||
if (isLoadingDefinition) {
|
||||
return (
|
||||
<div className="import-analysis">
|
||||
<div className="import-loading">
|
||||
<div className="import-spinner" />
|
||||
Loading import definition...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="import-analysis">
|
||||
<div className="import-analysis-header">
|
||||
<h2>Import Analysis</h2>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="import-definition-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') nameInputRef.current?.blur(); }}
|
||||
placeholder="Import name..."
|
||||
/>
|
||||
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
|
||||
</div>
|
||||
|
||||
@@ -103,27 +166,27 @@ export const ImportAnalysisView: React.FC = () => {
|
||||
</div>
|
||||
<div className="import-file-row">
|
||||
<label>WXR File</label>
|
||||
<div className={`import-file-path ${!report ? 'placeholder' : ''}`}>
|
||||
{report?.sourceFile || 'Select a file to analyze'}
|
||||
<div className={`import-file-path ${!wxrFilePath ? 'placeholder' : ''}`}>
|
||||
{wxrFilePath || report?.sourceFile || 'Select a file to analyze'}
|
||||
</div>
|
||||
<button
|
||||
className="import-analyze-btn"
|
||||
onClick={handleSelectAndAnalyze}
|
||||
disabled={importAnalysisLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'}
|
||||
{isLoading ? 'Analyzing...' : 'Select & Analyze'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importAnalysisLoading && (
|
||||
{isLoading && (
|
||||
<div className="import-loading">
|
||||
<div className="import-spinner" />
|
||||
Analyzing WXR file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report && !importAnalysisLoading && (
|
||||
{!report && !isLoading && (
|
||||
<div className="import-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
@@ -132,7 +195,7 @@ export const ImportAnalysisView: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && !importAnalysisLoading && (
|
||||
{report && !isLoading && (
|
||||
<>
|
||||
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
|
||||
<StatCards report={report} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, PostData, MediaData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import type { ChatConversation } from '../../types/electron';
|
||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||
import './Sidebar.css';
|
||||
|
||||
// Tag data with color information
|
||||
@@ -1268,6 +1268,127 @@ const ChatList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ImportList: React.FC = () => {
|
||||
const { openTab, closeTab } = useAppStore();
|
||||
const [definitions, setDefinitions] = useState<ImportDefinitionData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadDefinitions = useCallback(async () => {
|
||||
try {
|
||||
const defs = await window.electronAPI?.importDefinitions.getAll();
|
||||
if (defs) {
|
||||
setDefinitions(defs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load import definitions:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await loadDefinitions();
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [loadDefinitions]);
|
||||
|
||||
const handleNewDefinition = async () => {
|
||||
try {
|
||||
const def = await window.electronAPI?.importDefinitions.create();
|
||||
if (def) {
|
||||
setDefinitions(prev => [def, ...prev]);
|
||||
openTab({ type: 'import', id: def.id, isTransient: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create import definition:', error);
|
||||
showToast.error('Failed to create import definition');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDefinition = (definitionId: string) => {
|
||||
openTab({ type: 'import', id: definitionId, isTransient: false });
|
||||
};
|
||||
|
||||
const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await window.electronAPI?.importDefinitions.delete(definitionId);
|
||||
setDefinitions(prev => prev.filter(d => d.id !== definitionId));
|
||||
closeTab(definitionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete import definition:', error);
|
||||
showToast.error('Failed to delete import definition');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>IMPORTS</span>
|
||||
</div>
|
||||
<div className="chat-loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>IMPORTS</span>
|
||||
<button className="chat-new-button" onClick={handleNewDefinition} title="New Import Definition">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-list-items">
|
||||
{definitions.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>No import definitions yet</p>
|
||||
<button className="chat-start-button" onClick={handleNewDefinition}>
|
||||
Create an import definition
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
definitions.map(def => (
|
||||
<div
|
||||
key={def.id}
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenDefinition(def.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{def.name}</div>
|
||||
<div className="chat-item-date">{formatDate(def.updatedAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(e) => handleDeleteDefinition(e, def.id)}
|
||||
title="Delete import definition"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const { activeView, sidebarVisible } = useAppStore();
|
||||
|
||||
@@ -1282,6 +1403,7 @@ export const Sidebar: React.FC = () => {
|
||||
{activeView === 'settings' && <SettingsNav />}
|
||||
{activeView === 'tags' && <TagsNav />}
|
||||
{activeView === 'chat' && <ChatList />}
|
||||
{activeView === 'import' && <ImportList />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ const getTabTitle = (
|
||||
tab: Tab,
|
||||
posts: { id: string; title: string }[],
|
||||
media: { id: string; originalName: string }[],
|
||||
chatTitles: Map<string, string>
|
||||
chatTitles: Map<string, string>,
|
||||
importDefTitles: Map<string, string>
|
||||
): string => {
|
||||
if (tab.type === 'settings') {
|
||||
return 'Settings';
|
||||
@@ -40,7 +41,7 @@ const getTabTitle = (
|
||||
}
|
||||
|
||||
if (tab.type === 'import') {
|
||||
return 'Import Analysis';
|
||||
return importDefTitles.get(tab.id) || 'Import';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
@@ -129,6 +130,7 @@ export const TabBar: React.FC = () => {
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
|
||||
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Fetch chat titles for chat tabs
|
||||
useEffect(() => {
|
||||
@@ -175,6 +177,33 @@ export const TabBar: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch import definition titles for import tabs
|
||||
useEffect(() => {
|
||||
const importTabs = tabs.filter(t => t.type === 'import');
|
||||
if (importTabs.length === 0) return;
|
||||
|
||||
const fetchTitles = async () => {
|
||||
const newTitles = new Map(importDefTitles);
|
||||
for (const tab of importTabs) {
|
||||
if (!importDefTitles.has(tab.id)) {
|
||||
try {
|
||||
const def = await window.electronAPI?.importDefinitions.get(tab.id);
|
||||
if (def) {
|
||||
newTitles.set(tab.id, def.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch import definition title:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newTitles.size !== importDefTitles.size) {
|
||||
setImportDefTitles(newTitles);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTitles();
|
||||
}, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops
|
||||
|
||||
// Check if arrows are needed based on scroll position
|
||||
const updateArrowVisibility = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
@@ -305,7 +334,7 @@ export const TabBar: React.FC = () => {
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||
const title = getTabTitle(tab, posts, media, chatTitles);
|
||||
const title = getTabTitle(tab, posts, media, chatTitles, importDefTitles);
|
||||
const icon = getTabIcon(tab);
|
||||
|
||||
return (
|
||||
|
||||
@@ -127,10 +127,6 @@ interface AppState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Import Analysis
|
||||
importAnalysis: unknown | null;
|
||||
importAnalysisLoading: boolean;
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects: ProjectData[]) => void;
|
||||
setActiveProject: (project: ProjectData | null) => void;
|
||||
@@ -188,10 +184,6 @@ interface AppState {
|
||||
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (report: unknown | null) => void;
|
||||
setImportAnalysisLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -240,10 +232,6 @@ export const useAppStore = create<AppState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Import Analysis State
|
||||
importAnalysis: null,
|
||||
importAnalysisLoading: false,
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects) => set({ projects }),
|
||||
setActiveProject: (activeProject) => set({ activeProject }),
|
||||
@@ -417,10 +405,6 @@ export const useAppStore = create<AppState>()(
|
||||
// Loading Actions
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
|
||||
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
|
||||
18
src/renderer/types/electron.d.ts
vendored
18
src/renderer/types/electron.d.ts
vendored
@@ -1,5 +1,16 @@
|
||||
// Type definitions for the Electron API exposed via preload
|
||||
|
||||
export interface ImportDefinitionData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
wxrFilePath: string | null;
|
||||
uploadsFolderPath: string | null;
|
||||
lastAnalysisResult: unknown | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -386,6 +397,13 @@ export interface ElectronAPI {
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
};
|
||||
importDefinitions: {
|
||||
create: (name?: string) => Promise<ImportDefinitionData>;
|
||||
get: (id: string) => Promise<ImportDefinitionData | null>;
|
||||
getAll: () => Promise<ImportDefinitionData[]>;
|
||||
update: (id: string, updates: Partial<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>) => Promise<ImportDefinitionData | null>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
|
||||
Reference in New Issue
Block a user