feat: importer starting point

This commit is contained in:
2026-02-13 13:07:44 +01:00
parent deb0f3ae3b
commit d88fb1d9fa
19 changed files with 2666 additions and 10 deletions

View File

@@ -37,6 +37,12 @@ const ChatIcon = () => (
</svg>
);
const ImportIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
);
const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
@@ -60,6 +66,9 @@ 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);
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
if (activeView === view && sidebarVisible) {
@@ -96,6 +105,11 @@ export const ActivityBar: React.FC = () => {
openTab({ type: 'tags', id: 'tags', isTransient: false });
};
const handleImportClick = () => {
// Open import as a dedicated (non-transient) tab
openTab({ type: 'import', id: 'import', isTransient: false });
};
return (
<div className="activity-bar">
<div className="activity-bar-top">
@@ -127,6 +141,13 @@ export const ActivityBar: React.FC = () => {
>
<ChatIcon />
</button>
<button
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
onClick={handleImportClick}
title="Import Analysis"
>
<ImportIcon />
</button>
</div>
<div className="activity-bar-bottom">

View File

@@ -12,6 +12,7 @@ import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput';
import { ChatPanel } from '../ChatPanel';
import { ImportAnalysisView } from '../ImportAnalysisView';
import { AutoSaveManager } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry';
import { PostSearchModal } from '../PostSearchModal';
@@ -1531,6 +1532,7 @@ export const Editor: React.FC = () => {
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
const showChat = activeTab?.type === 'chat';
const showImport = activeTab?.type === 'import';
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => {
@@ -1619,6 +1621,17 @@ export const Editor: React.FC = () => {
);
}
// Show import analysis if import tab is active
if (showImport) {
return (
<div className="editor">
<ImportAnalysisView />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
// Show post editor if a post tab is active
if (showPost && activeTabId) {
const post = posts.find(p => p.id === activeTabId);

View File

@@ -0,0 +1,381 @@
.import-analysis {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 24px;
gap: 20px;
}
.import-analysis-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.import-analysis-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--vscode-foreground);
}
.import-analysis-header p {
margin: 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
/* File selection area */
.import-file-selectors {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--vscode-sideBar-background);
padding: 16px;
border-radius: 6px;
}
.import-file-row {
display: flex;
align-items: center;
gap: 10px;
}
.import-file-row label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
min-width: 100px;
flex-shrink: 0;
}
.import-file-path {
flex: 1;
font-size: 12px;
color: var(--vscode-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 4px;
padding: 6px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.import-file-path.placeholder {
color: var(--vscode-input-placeholderForeground);
font-style: italic;
}
.import-file-row button {
padding: 6px 12px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.import-file-row button:hover {
background: var(--vscode-button-hoverBackground);
}
.import-analyze-btn {
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
align-self: flex-start;
}
.import-analyze-btn:hover {
background: var(--vscode-button-hoverBackground);
}
.import-analyze-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading state */
.import-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
font-size: 13px;
color: var(--vscode-descriptionForeground);
}
.import-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Site info card */
.import-site-info {
display: flex;
gap: 20px;
background: var(--vscode-sideBar-background);
padding: 16px;
border-radius: 6px;
}
.import-site-info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-site-info-item .info-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.import-site-info-item .info-value {
font-size: 13px;
color: var(--vscode-foreground);
}
/* Stat cards grid */
.import-stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.import-stat-card {
background: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
}
.import-stat-card h3 {
margin: 0 0 10px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.import-stat-number {
font-size: 32px;
font-weight: 600;
margin-bottom: 4px;
color: var(--vscode-foreground);
}
.import-stat-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
}
.import-stat-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.import-stat-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.import-stat-tag.stat-new {
background: rgba(115, 201, 145, 0.15);
color: #73c991;
}
.import-stat-tag.stat-update {
background: rgba(117, 190, 255, 0.15);
color: #75beff;
}
.import-stat-tag.stat-conflict {
background: rgba(244, 135, 113, 0.15);
color: #f48771;
}
.import-stat-tag.stat-duplicate {
background: rgba(204, 167, 0, 0.15);
color: #cca700;
}
.import-stat-tag.stat-missing {
background: rgba(150, 150, 150, 0.15);
color: #969696;
}
/* Detail sections */
.import-detail-section {
background: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
}
.import-detail-section h3 {
margin: 0 0 12px 0;
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.import-detail-section h3 .toggle-icon {
font-size: 10px;
transition: transform 0.15s;
}
.import-detail-section h3 .toggle-icon.open {
transform: rotate(90deg);
}
/* Detail tables */
.import-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.import-detail-table th {
text-align: left;
padding: 6px 10px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.1));
}
.import-detail-table td {
padding: 6px 10px;
color: var(--vscode-foreground);
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06));
}
.import-detail-table tr:last-child td {
border-bottom: none;
}
.import-detail-table .status-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 10px;
}
.import-detail-table .status-badge.new {
background: rgba(115, 201, 145, 0.15);
color: #73c991;
}
.import-detail-table .status-badge.update {
background: rgba(117, 190, 255, 0.15);
color: #75beff;
}
.import-detail-table .status-badge.conflict {
background: rgba(244, 135, 113, 0.15);
color: #f48771;
}
.import-detail-table .status-badge.content-duplicate {
background: rgba(204, 167, 0, 0.15);
color: #cca700;
}
.import-detail-table .status-badge.missing {
background: rgba(150, 150, 150, 0.15);
color: #969696;
}
.import-detail-table .slug-cell {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-detail-table .existing-match {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
/* Tag/category pills */
.import-taxonomy-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.import-taxonomy-pill {
font-size: 11px;
padding: 3px 10px;
border-radius: 10px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.import-taxonomy-pill.exists {
background: rgba(115, 201, 145, 0.15);
color: #73c991;
}
.import-taxonomy-pill.new-tax {
background: rgba(117, 190, 255, 0.15);
color: #75beff;
}
/* Empty state */
.import-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
.import-empty-state svg {
opacity: 0.3;
}
.import-empty-state p {
margin: 0;
font-size: 13px;
}

View File

@@ -0,0 +1,432 @@
import React, { useState, useCallback } from 'react';
import { useAppStore } from '../../store';
import './ImportAnalysisView.css';
interface AnalysisReport {
sourceFile: string;
site: { title: string; link: string; description: string; language: string };
analyzedAt: string;
posts: ItemSection;
pages: ItemSection;
media: MediaSection;
categories: TaxonomyItem[];
tags: TaxonomyItem[];
}
interface ItemSection {
total: number;
new: number;
updates: number;
conflicts: number;
contentDuplicates: number;
items: AnalyzedPostItem[];
}
interface MediaSection {
total: number;
new: number;
updates: number;
conflicts: number;
contentDuplicates: number;
missing: number;
items: AnalyzedMediaItem[];
}
interface AnalyzedPostItem {
wxrPost: { wpId: number; title: string; slug: string; status: string };
status: string;
contentHash: string;
markdownPreview: string;
existingPost?: { id: string; title: string; slug: string };
}
interface AnalyzedMediaItem {
wxrMedia: { wpId: number; title: string; filename: string; url: string; relativePath: string };
status: string;
fileHash: string | null;
existingMedia?: { id: string; originalName: string };
}
interface TaxonomyItem {
name: string;
slug: string;
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>>({});
const report = importAnalysis as AnalysisReport | null;
const handleSelectUploadsFolder = useCallback(async () => {
const folder = await window.electronAPI?.import.selectUploadsFolder();
if (folder) {
setUploadsFolder(folder);
}
}, []);
const handleSelectAndAnalyze = useCallback(async () => {
setImportAnalysisLoading(true);
setImportAnalysis(null);
try {
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined);
if (result) {
setImportAnalysis(result);
}
} catch (error) {
console.error('Import analysis failed:', error);
} finally {
setImportAnalysisLoading(false);
}
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]);
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
}, []);
return (
<div className="import-analysis">
<div className="import-analysis-header">
<h2>Import Analysis</h2>
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
</div>
<div className="import-file-selectors">
<div className="import-file-row">
<label>Uploads Folder</label>
<div className={`import-file-path ${!uploadsFolder ? 'placeholder' : ''}`}>
{uploadsFolder || 'No folder selected'}
</div>
<button onClick={handleSelectUploadsFolder}>Browse...</button>
</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>
<button
className="import-analyze-btn"
onClick={handleSelectAndAnalyze}
disabled={importAnalysisLoading}
>
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'}
</button>
</div>
</div>
{importAnalysisLoading && (
<div className="import-loading">
<div className="import-spinner" />
Analyzing WXR file...
</div>
)}
{!report && !importAnalysisLoading && (
<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"/>
</svg>
<p>Select a WordPress export file to begin analysis.</p>
</div>
)}
{report && !importAnalysisLoading && (
<>
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
<StatCards report={report} />
{report.posts.conflicts > 0 && (
<ConflictsSection
title="Post Slug Conflicts"
items={report.posts.items.filter(i => i.status === 'conflict')}
expanded={expandedSections['post-conflicts'] ?? true}
onToggle={() => toggleSection('post-conflicts')}
/>
)}
{report.pages.conflicts > 0 && (
<ConflictsSection
title="Page Slug Conflicts"
items={report.pages.items.filter(i => i.status === 'conflict')}
expanded={expandedSections['page-conflicts'] ?? true}
onToggle={() => toggleSection('page-conflicts')}
/>
)}
<PostDetailSection
title={`Posts (${report.posts.total})`}
items={report.posts.items}
expanded={expandedSections['posts'] ?? false}
onToggle={() => toggleSection('posts')}
/>
{report.pages.total > 0 && (
<PostDetailSection
title={`Pages (${report.pages.total})`}
items={report.pages.items}
expanded={expandedSections['pages'] ?? false}
onToggle={() => toggleSection('pages')}
/>
)}
<MediaDetailSection
title={`Media (${report.media.total})`}
items={report.media.items}
expanded={expandedSections['media'] ?? false}
onToggle={() => toggleSection('media')}
/>
{(report.categories.length > 0 || report.tags.length > 0) && (
<TaxonomySection
categories={report.categories}
tags={report.tags}
expanded={expandedSections['taxonomy'] ?? false}
onToggle={() => toggleSection('taxonomy')}
/>
)}
</>
)}
</div>
);
};
const SiteInfoCard: React.FC<{ site: AnalysisReport['site']; sourceFile: string }> = ({ site, sourceFile }) => (
<div className="import-site-info">
<div className="import-site-info-item">
<span className="info-label">Site</span>
<span className="info-value">{site.title || 'Untitled'}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">URL</span>
<span className="info-value">{site.link || 'N/A'}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">Language</span>
<span className="info-value">{site.language || 'N/A'}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">File</span>
<span className="info-value">{sourceFile.split(/[/\\]/).pop()}</span>
</div>
</div>
);
const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => (
<div className="import-stat-cards">
<div className="import-stat-card">
<h3>Posts</h3>
<div className="import-stat-number">{report.posts.total}</div>
<div className="import-stat-breakdown">
{report.posts.new > 0 && <span className="import-stat-tag stat-new">{report.posts.new} new</span>}
{report.posts.updates > 0 && <span className="import-stat-tag stat-update">{report.posts.updates} update</span>}
{report.posts.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.posts.conflicts} conflict</span>}
{report.posts.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.posts.contentDuplicates} duplicate</span>}
</div>
</div>
<div className="import-stat-card">
<h3>Pages</h3>
<div className="import-stat-number">{report.pages.total}</div>
<div className="import-stat-breakdown">
{report.pages.new > 0 && <span className="import-stat-tag stat-new">{report.pages.new} new</span>}
{report.pages.updates > 0 && <span className="import-stat-tag stat-update">{report.pages.updates} update</span>}
{report.pages.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.pages.conflicts} conflict</span>}
{report.pages.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.pages.contentDuplicates} duplicate</span>}
</div>
</div>
<div className="import-stat-card">
<h3>Media</h3>
<div className="import-stat-number">{report.media.total}</div>
<div className="import-stat-breakdown">
{report.media.new > 0 && <span className="import-stat-tag stat-new">{report.media.new} new</span>}
{report.media.updates > 0 && <span className="import-stat-tag stat-update">{report.media.updates} update</span>}
{report.media.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.media.conflicts} conflict</span>}
{report.media.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.media.contentDuplicates} duplicate</span>}
{report.media.missing > 0 && <span className="import-stat-tag stat-missing">{report.media.missing} missing</span>}
</div>
</div>
<div className="import-stat-card">
<h3>Categories</h3>
<div className="import-stat-number">{report.categories.length}</div>
<div className="import-stat-breakdown">
{report.categories.filter(c => c.existsInProject).length > 0 && (
<span className="import-stat-tag stat-update">{report.categories.filter(c => c.existsInProject).length} existing</span>
)}
{report.categories.filter(c => !c.existsInProject).length > 0 && (
<span className="import-stat-tag stat-new">{report.categories.filter(c => !c.existsInProject).length} new</span>
)}
</div>
</div>
<div className="import-stat-card">
<h3>Tags</h3>
<div className="import-stat-number">{report.tags.length}</div>
<div className="import-stat-breakdown">
{report.tags.filter(t => t.existsInProject).length > 0 && (
<span className="import-stat-tag stat-update">{report.tags.filter(t => t.existsInProject).length} existing</span>
)}
{report.tags.filter(t => !t.existsInProject).length > 0 && (
<span className="import-stat-tag stat-new">{report.tags.filter(t => !t.existsInProject).length} new</span>
)}
</div>
</div>
</div>
);
const ConflictsSection: React.FC<{
title: string;
items: AnalyzedPostItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ title, items, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title} ({items.length})
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>Slug</th>
<th>WXR Title</th>
<th>Existing Title</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td className="slug-cell">{item.wxrPost.slug}</td>
<td>{item.wxrPost.title}</td>
<td className="existing-match">{item.existingPost?.title || '--'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
const PostDetailSection: React.FC<{
title: string;
items: AnalyzedPostItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ title, items, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>Status</th>
<th>Title</th>
<th>Slug</th>
<th>WP Status</th>
<th>Existing Match</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
<td>{item.wxrPost.title}</td>
<td className="slug-cell">{item.wxrPost.slug}</td>
<td>{item.wxrPost.status}</td>
<td className="existing-match">{item.existingPost?.title || '--'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
const MediaDetailSection: React.FC<{
title: string;
items: AnalyzedMediaItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ title, items, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>Status</th>
<th>Filename</th>
<th>Path</th>
<th>Existing Match</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
<td>{item.wxrMedia.filename}</td>
<td className="slug-cell">{item.wxrMedia.relativePath}</td>
<td className="existing-match">{item.existingMedia?.originalName || '--'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
const TaxonomySection: React.FC<{
categories: TaxonomyItem[];
tags: TaxonomyItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ categories, tags, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
Categories & Tags
</h3>
{expanded && (
<>
{categories.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Categories
</div>
<div className="import-taxonomy-list">
{categories.map((cat, idx) => (
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
{cat.name}
</span>
))}
</div>
</div>
)}
{tags.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Tags
</div>
<div className="import-taxonomy-list">
{tags.map((tag, idx) => (
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
{tag.name}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
);

View File

@@ -0,0 +1 @@
export { ImportAnalysisView } from './ImportAnalysisView';

View File

@@ -32,13 +32,17 @@ const getTabTitle = (
const title = chatTitles.get(tab.id);
if (title && title !== 'New Chat') {
// Truncate long titles for display
return title.length > MAX_CHAT_TITLE_LENGTH
return title.length > MAX_CHAT_TITLE_LENGTH
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
: title;
}
return 'New Chat';
}
if (tab.type === 'import') {
return 'Import Analysis';
}
return 'Unknown';
};
@@ -74,6 +78,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M14 1H2a1 1 0 00-1 1v10a1 1 0 001 1h3v2.5l4-2.5h5a1 1 0 001-1V2a1 1 0 00-1-1zm0 11H8.5L5 14v-2H2V2h12v10z"/>
</svg>
);
case 'import':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
);
default:
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -19,3 +19,4 @@ export { LinkedMediaPanel } from './LinkedMediaPanel';
export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
export { ChatPanel } from './ChatPanel';
export { ImportAnalysisView } from './ImportAnalysisView';

View File

@@ -6,7 +6,7 @@ import type { DeleteReference, ConfirmDeleteDetails } from '../components/Confir
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
export interface Tab {
type: TabType;
@@ -93,7 +93,7 @@ interface AppState {
activeTabId: string | null;
// UI State
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
@@ -126,7 +126,11 @@ interface AppState {
// Loading states
isLoading: boolean;
error: string | null;
// Import Analysis
importAnalysis: unknown | null;
importAnalysisLoading: boolean;
// Project Actions
setProjects: (projects: ProjectData[]) => void;
setActiveProject: (project: ProjectData | null) => void;
@@ -144,7 +148,7 @@ interface AppState {
restoreTabState: (state: TabState) => void;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;
@@ -184,6 +188,10 @@ 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>()(
@@ -231,7 +239,11 @@ export const useAppStore = create<AppState>()(
// Initial Loading State
isLoading: false,
error: null,
// Import Analysis State
importAnalysis: null,
importAnalysisLoading: false,
// Project Actions
setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }),
@@ -405,6 +417,10 @@ 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,

View File

@@ -381,6 +381,11 @@ export interface ElectronAPI {
getPostsWithTag: (tagId: string) => Promise<string[]>;
syncFromPosts: () => Promise<SyncTagsResult>;
};
import: {
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
selectUploadsFolder: () => Promise<string | null>;
};
chat: {
// API Key Management
checkReady: () => Promise<ChatReadyStatus>;