feat: proper tab handling
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, ToastContainer, showToast } from './components';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast } from './components';
|
||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||
import './App.css';
|
||||
|
||||
@@ -23,6 +23,7 @@ const App: React.FC = () => {
|
||||
togglePanel,
|
||||
setActiveView,
|
||||
setSelectedPost,
|
||||
openTab,
|
||||
} = useAppStore();
|
||||
|
||||
// Load initial data
|
||||
@@ -257,7 +258,7 @@ const App: React.FC = () => {
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:configureSync', () => {
|
||||
setActiveView('settings');
|
||||
openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
@@ -320,6 +321,7 @@ const App: React.FC = () => {
|
||||
<ActivityBar />
|
||||
<Sidebar />
|
||||
<div className="app-content">
|
||||
<TabBar />
|
||||
<Editor />
|
||||
<Panel />
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,17 @@ const SyncIcon = () => (
|
||||
);
|
||||
|
||||
export const ActivityBar: React.FC = () => {
|
||||
const { activeView, setActiveView, syncStatus, pendingChanges } = useAppStore();
|
||||
const { activeView, setActiveView, syncStatus, pendingChanges, openTab, tabs, activeTabId } = useAppStore();
|
||||
|
||||
const totalPending = pendingChanges.posts + pendingChanges.media;
|
||||
|
||||
// Check if settings tab is currently active
|
||||
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
// Open settings as a dedicated (non-transient) tab
|
||||
openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="activity-bar">
|
||||
@@ -64,8 +72,8 @@ export const ActivityBar: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${activeView === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={`activity-bar-item ${isSettingsTabActive ? 'active' : ''}`}
|
||||
onClick={handleSettingsClick}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
|
||||
@@ -7,8 +7,45 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
import { PostLinks } from '../PostLinks';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import './Editor.css';
|
||||
|
||||
// Module-level AutoSaveManager for idle-time based auto-saving
|
||||
const autoSaveManager = new AutoSaveManager({
|
||||
idleTimeMs: 3000, // Save after 3 seconds of idle time
|
||||
onSave: async (id, changes) => {
|
||||
const state = useAppStore.getState();
|
||||
// Only save if post still exists in store
|
||||
const postExists = state.posts.some(p => p.id === id);
|
||||
if (!postExists) return;
|
||||
|
||||
// Build update payload from changes
|
||||
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
|
||||
if ('title' in changes) update.title = changes.title as string;
|
||||
if ('content' in changes) update.content = changes.content as string;
|
||||
if ('tags' in changes) {
|
||||
const tagsStr = changes.tags as string;
|
||||
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
}
|
||||
if ('category' in changes) {
|
||||
const cat = changes.category as string;
|
||||
update.categories = cat ? [cat] : ['article'];
|
||||
}
|
||||
|
||||
const updated = await window.electronAPI?.posts.update(id, update);
|
||||
if (updated) {
|
||||
useAppStore.getState().updatePost(id, updated as Partial<PostData>);
|
||||
useAppStore.getState().markClean(id);
|
||||
}
|
||||
},
|
||||
onSaveComplete: (id) => {
|
||||
console.log(`Auto-saved post ${id}`);
|
||||
},
|
||||
onSaveError: (id, error) => {
|
||||
console.error(`Auto-save failed for ${id}:`, error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves media references in markdown content to bds-media:// URLs
|
||||
* Matches images by:
|
||||
@@ -176,6 +213,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const prevPostId = post.id;
|
||||
|
||||
return () => {
|
||||
// Cancel any pending auto-save timer - we'll save immediately
|
||||
autoSaveManager.cancel(prevPostId);
|
||||
|
||||
const pending = pendingChangesRef.current;
|
||||
// Only auto-save if the post still exists in the store (not deleted/discarded)
|
||||
const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId);
|
||||
@@ -207,7 +247,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
markClean(post.id);
|
||||
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
|
||||
|
||||
// Track changes
|
||||
// Track changes and notify auto-save manager
|
||||
useEffect(() => {
|
||||
const currentCategory = post.categories[0] || 'article';
|
||||
const hasChanges =
|
||||
@@ -218,6 +258,13 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
|
||||
if (hasChanges) {
|
||||
markDirty(post.id);
|
||||
// Notify auto-save manager with accumulated changes
|
||||
autoSaveManager.notifyChange(post.id, {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
category,
|
||||
});
|
||||
} else {
|
||||
markClean(post.id);
|
||||
}
|
||||
@@ -232,6 +279,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isDirty || isSaving) return;
|
||||
|
||||
// Cancel any pending auto-save since we're saving manually
|
||||
autoSaveManager.cancel(post.id);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updated = await window.electronAPI?.posts.update(post.id, {
|
||||
@@ -934,6 +984,12 @@ const Dashboard: React.FC = () => {
|
||||
onClick={() => {
|
||||
useAppStore.getState().setActiveView('posts');
|
||||
useAppStore.getState().setSelectedPost(post.id);
|
||||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: true });
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
useAppStore.getState().setActiveView('posts');
|
||||
useAppStore.getState().setSelectedPost(post.id);
|
||||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: false });
|
||||
}}
|
||||
>
|
||||
<span className="recent-post-title">{post.title || 'Untitled'}</span>
|
||||
@@ -953,7 +1009,9 @@ export const Editor: React.FC = () => {
|
||||
const {
|
||||
activeView,
|
||||
selectedPostId,
|
||||
selectedMediaId,
|
||||
selectedMediaId,
|
||||
tabs,
|
||||
activeTabId,
|
||||
posts,
|
||||
media,
|
||||
errorModal,
|
||||
@@ -961,8 +1019,17 @@ export const Editor: React.FC = () => {
|
||||
isLoading,
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
closeTab,
|
||||
} = useAppStore();
|
||||
|
||||
// Get the active tab
|
||||
const activeTab = tabs.find(t => t.id === activeTabId);
|
||||
|
||||
// Determine what to show based on active tab
|
||||
const showPost = activeTab?.type === 'post';
|
||||
const showMedia = activeTab?.type === 'media';
|
||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||
|
||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||
useEffect(() => {
|
||||
if (activeView === 'posts' && selectedPostId && !isLoading) {
|
||||
@@ -983,12 +1050,30 @@ export const Editor: React.FC = () => {
|
||||
}
|
||||
}, [activeView, selectedMediaId, media, isLoading, setSelectedMedia]);
|
||||
|
||||
// Close tab if the item doesn't exist anymore
|
||||
useEffect(() => {
|
||||
if (activeTab && !isLoading) {
|
||||
if (activeTab.type === 'post') {
|
||||
const postExists = posts.some(p => p.id === activeTab.id);
|
||||
if (!postExists) {
|
||||
closeTab(activeTab.id);
|
||||
}
|
||||
} else if (activeTab.type === 'media') {
|
||||
const mediaExists = media.some(m => m.id === activeTab.id);
|
||||
if (!mediaExists) {
|
||||
closeTab(activeTab.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeTab, posts, media, isLoading, closeTab]);
|
||||
|
||||
// Show error modal if present
|
||||
const renderErrorModal = () => (
|
||||
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
||||
);
|
||||
|
||||
if (activeView === 'settings') {
|
||||
// Show settings if settings tab is active or settings view with no tab
|
||||
if (showSettings) {
|
||||
return (
|
||||
<>
|
||||
<SettingsView />
|
||||
@@ -997,18 +1082,19 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeView === 'posts' && selectedPostId) {
|
||||
const post = posts.find(p => p.id === selectedPostId);
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
if (post) {
|
||||
return (
|
||||
<>
|
||||
<PostEditor post={post} />
|
||||
<PostEditor key={post.id} post={post} />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Post not found - show loading or empty state while useEffect clears selection
|
||||
// Post not found - show loading or empty state
|
||||
return (
|
||||
<>
|
||||
<div className="editor-empty">
|
||||
@@ -1021,15 +1107,17 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeView === 'media' && selectedMediaId) {
|
||||
// Show media editor if a media tab is active
|
||||
if (showMedia && activeTabId) {
|
||||
return (
|
||||
<>
|
||||
<MediaEditor mediaId={selectedMediaId} />
|
||||
<MediaEditor key={activeTabId} mediaId={activeTabId} />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// No tab active - show dashboard
|
||||
return (
|
||||
<>
|
||||
<Dashboard />
|
||||
|
||||
@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
};
|
||||
|
||||
const PostsList: React.FC = () => {
|
||||
const { posts, selectedPostId, setSelectedPost, hasMorePosts, totalPosts, appendPosts } = useAppStore();
|
||||
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -379,6 +379,15 @@ const PostsList: React.FC = () => {
|
||||
setFilteredPosts(null);
|
||||
};
|
||||
|
||||
// Click handlers for tabs
|
||||
const handlePostClick = (postId: string) => {
|
||||
openTab({ type: 'post', id: postId, isTransient: true });
|
||||
};
|
||||
|
||||
const handlePostDoubleClick = (postId: string) => {
|
||||
openTab({ type: 'post', id: postId, isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
@@ -447,8 +456,9 @@ const PostsList: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => handlePostClick(post.id)}
|
||||
onDoubleClick={() => handlePostDoubleClick(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
@@ -474,8 +484,9 @@ const PostsList: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => handlePostClick(post.id)}
|
||||
onDoubleClick={() => handlePostDoubleClick(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
@@ -501,8 +512,9 @@ const PostsList: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => handlePostClick(post.id)}
|
||||
onDoubleClick={() => handlePostDoubleClick(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
@@ -547,7 +559,7 @@ const PostsList: React.FC = () => {
|
||||
};
|
||||
|
||||
const MediaList: React.FC = () => {
|
||||
const { media, selectedMediaId, setSelectedMedia } = useAppStore();
|
||||
const { media, openTab, activeTabId } = useAppStore();
|
||||
|
||||
const handleImportMedia = async () => {
|
||||
try {
|
||||
@@ -557,6 +569,14 @@ const MediaList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaClick = (mediaId: string) => {
|
||||
openTab({ type: 'media', id: mediaId, isTransient: true });
|
||||
};
|
||||
|
||||
const handleMediaDoubleClick = (mediaId: string) => {
|
||||
openTab({ type: 'media', id: mediaId, isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
@@ -574,8 +594,9 @@ const MediaList: React.FC = () => {
|
||||
{media.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`media-item ${selectedMediaId === item.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedMedia(item.id)}
|
||||
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
|
||||
onClick={() => handleMediaClick(item.id)}
|
||||
onDoubleClick={() => handleMediaDoubleClick(item.id)}
|
||||
title={item.originalName}
|
||||
>
|
||||
{item.mimeType.startsWith('image/') ? (
|
||||
|
||||
200
src/renderer/components/TabBar/TabBar.css
Normal file
200
src/renderer/components/TabBar/TabBar.css
Normal file
@@ -0,0 +1,200 @@
|
||||
/* TabBar Styles - VS Code inspired */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
|
||||
height: 35px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.tab-bar-tabs::-webkit-scrollbar {
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.tab-scroll-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||
border: none;
|
||||
color: var(--vscode-icon-foreground, #c5c5c5);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab-scroll-button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.tab-scroll-left {
|
||||
border-right: 1px solid var(--vscode-tab-border, #252526);
|
||||
}
|
||||
|
||||
.tab-scroll-right {
|
||||
border-left: 1px solid var(--vscode-tab-border, #252526);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 100%;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
background-color: var(--vscode-tab-inactiveBackground, #2d2d2d);
|
||||
border-right: 1px solid var(--vscode-tab-border, #252526);
|
||||
color: var(--vscode-tab-inactiveForeground, #969696);
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--vscode-tab-hoverBackground, #2a2d2e);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: var(--vscode-tab-activeBackground, #1e1e1e);
|
||||
color: var(--vscode-tab-activeForeground, #fff);
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--vscode-tab-activeBorderTop, #0078d4);
|
||||
}
|
||||
|
||||
.tab.transient .tab-title {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab.active .tab-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-title.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-dirty-indicator {
|
||||
color: var(--vscode-editorWarning-foreground, #e2c08d);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-icon-foreground, #c5c5c5);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab.active .tab-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1 !important;
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.tab-close:active {
|
||||
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
|
||||
}
|
||||
|
||||
/* When tab is dirty, show close button on hover, swap with dirty indicator */
|
||||
.tab.dirty .tab-dirty-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab.dirty .tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab.dirty:hover .tab-close {
|
||||
display: flex;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab.dirty:hover .tab-dirty-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Focus styles for keyboard navigation */
|
||||
.tab:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder, #007fd4);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Empty state when no tabs */
|
||||
.tab-bar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: var(--vscode-descriptionForeground, #999);
|
||||
font-size: 12px;
|
||||
}
|
||||
249
src/renderer/components/TabBar/TabBar.tsx
Normal file
249
src/renderer/components/TabBar/TabBar.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, Tab } from '../../store';
|
||||
import './TabBar.css';
|
||||
|
||||
const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { id: string; originalName: string }[]): string => {
|
||||
if (tab.type === 'settings') {
|
||||
return 'Settings';
|
||||
}
|
||||
|
||||
if (tab.type === 'post') {
|
||||
const post = posts.find(p => p.id === tab.id);
|
||||
return post?.title || 'Untitled';
|
||||
}
|
||||
|
||||
if (tab.type === 'media') {
|
||||
const mediaItem = media.find(m => m.id === tab.id);
|
||||
return mediaItem?.originalName || 'Media';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
switch (tab.type) {
|
||||
case 'post':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'media':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14.5 2h-13l-.5.5v11l.5.5h13l.5-.5v-11l-.5-.5zM14 13H2V3h12v10zm-3.5-4.5L9 7.5l-2 2.5-1.5-1L3 12h10l-2.5-3.5z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CloseIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronLeftIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.354 3.146l.707.708L6.768 8l4.293 4.146-.707.708L5.354 8l5-4.854z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M5.646 12.854l-.707-.708L9.232 8 4.939 3.854l.707-.708L10.646 8l-5 4.854z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TabBar: React.FC = () => {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
posts,
|
||||
media,
|
||||
dirtyPosts,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
pinTab,
|
||||
} = useAppStore();
|
||||
|
||||
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
|
||||
// Check if arrows are needed based on scroll position
|
||||
const updateArrowVisibility = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft + clientWidth < scrollWidth - 1);
|
||||
}, []);
|
||||
|
||||
// Update arrow visibility on scroll or resize
|
||||
useEffect(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
updateArrowVisibility();
|
||||
|
||||
container.addEventListener('scroll', updateArrowVisibility);
|
||||
const resizeObserver = new ResizeObserver(updateArrowVisibility);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateArrowVisibility);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [updateArrowVisibility, tabs]);
|
||||
|
||||
// Scroll to active tab when it changes
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !tabsContainerRef.current) return;
|
||||
|
||||
const container = tabsContainerRef.current;
|
||||
const activeTab = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement;
|
||||
if (activeTab) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
if (tabRect.left < containerRect.left) {
|
||||
container.scrollLeft -= containerRect.left - tabRect.left + 10;
|
||||
} else if (tabRect.right > containerRect.right) {
|
||||
container.scrollLeft += tabRect.right - containerRect.right + 10;
|
||||
}
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
// Keyboard shortcut handler (Ctrl+W to close active tab)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
if (activeTabId) {
|
||||
closeTab(activeTabId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeTabId, closeTab]);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabDoubleClick = (tab: Tab) => {
|
||||
// Double-click on transient tab pins it
|
||||
if (tab.isTransient) {
|
||||
pinTab(tab.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tabId);
|
||||
};
|
||||
|
||||
const handleMiddleClick = (e: React.MouseEvent, tabId: string) => {
|
||||
// Middle-click closes the tab
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
closeTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollLeft = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollBy({ left: -150, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollBy({ left: 150, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-left"
|
||||
onClick={scrollLeft}
|
||||
title="Scroll tabs left"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="tab-bar-tabs" ref={tabsContainerRef}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||
const title = getTabTitle(tab, posts, media);
|
||||
const icon = getTabIcon(tab);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
className={`tab ${isActive ? 'active' : ''} ${tab.isTransient ? 'transient' : ''} ${isDirty ? 'dirty' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onDoubleClick={() => handleTabDoubleClick(tab)}
|
||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
||||
title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`}
|
||||
>
|
||||
<span className="tab-icon">{icon}</span>
|
||||
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
<div className="tab-actions">
|
||||
{isDirty && <span className="tab-dirty-indicator">●</span>}
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleTabClose(e, tab.id)}
|
||||
title="Close (Ctrl+W)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showRightArrow && (
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-right"
|
||||
onClick={scrollRight}
|
||||
title="Scroll tabs right"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
1
src/renderer/components/TabBar/index.ts
Normal file
1
src/renderer/components/TabBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TabBar } from './TabBar';
|
||||
@@ -3,6 +3,7 @@ export { Sidebar } from './Sidebar';
|
||||
export { Editor } from './Editor';
|
||||
export { StatusBar } from './StatusBar';
|
||||
export { Panel } from './Panel';
|
||||
export { TabBar } from './TabBar';
|
||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||
export { ProjectSelector } from './ProjectSelector';
|
||||
export { WysiwygEditor } from './WysiwygEditor';
|
||||
|
||||
@@ -4,6 +4,20 @@ import { persist } from 'zustand/middleware';
|
||||
// Storage key for persisted state
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
id: string;
|
||||
isTransient: boolean;
|
||||
}
|
||||
|
||||
export interface TabState {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface ProjectData {
|
||||
id: string;
|
||||
@@ -69,6 +83,10 @@ interface AppState {
|
||||
projects: ProjectData[];
|
||||
activeProject: ProjectData | null;
|
||||
|
||||
// Tabs
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings';
|
||||
sidebarVisible: boolean;
|
||||
@@ -108,6 +126,15 @@ interface AppState {
|
||||
updateProject: (id: string, project: Partial<ProjectData>) => void;
|
||||
removeProject: (id: string) => void;
|
||||
|
||||
// Tab Actions
|
||||
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
pinTab: (id: string) => void;
|
||||
clearTabs: () => void;
|
||||
getTabState: () => TabState;
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
|
||||
toggleSidebar: () => void;
|
||||
@@ -154,6 +181,10 @@ export const useAppStore = create<AppState>()(
|
||||
projects: [],
|
||||
activeProject: null,
|
||||
|
||||
// Initial Tabs State
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
|
||||
// Initial UI State
|
||||
activeView: 'posts',
|
||||
sidebarVisible: true,
|
||||
@@ -197,6 +228,82 @@ export const useAppStore = create<AppState>()(
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
})),
|
||||
|
||||
// Tab Actions
|
||||
openTab: ({ type, id, isTransient }) => set((state) => {
|
||||
const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type);
|
||||
|
||||
if (existingTabIndex >= 0) {
|
||||
// Tab already exists - if trying to pin (isTransient=false), update it
|
||||
if (!isTransient) {
|
||||
const updatedTabs = [...state.tabs];
|
||||
updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false };
|
||||
return { tabs: updatedTabs, activeTabId: id };
|
||||
}
|
||||
// Just switch to the existing tab
|
||||
return { activeTabId: id };
|
||||
}
|
||||
|
||||
// If opening as transient, replace existing transient tab of the same type
|
||||
if (isTransient) {
|
||||
const transientIndex = state.tabs.findIndex((t) => t.isTransient && t.type === type);
|
||||
if (transientIndex >= 0) {
|
||||
const updatedTabs = [...state.tabs];
|
||||
updatedTabs[transientIndex] = { type, id, isTransient: true };
|
||||
return { tabs: updatedTabs, activeTabId: id };
|
||||
}
|
||||
}
|
||||
|
||||
// Add new tab
|
||||
const newTab: Tab = { type, id, isTransient };
|
||||
return { tabs: [...state.tabs, newTab], activeTabId: id };
|
||||
}),
|
||||
|
||||
closeTab: (id) => set((state) => {
|
||||
const tabIndex = state.tabs.findIndex((t) => t.id === id);
|
||||
if (tabIndex === -1) return state;
|
||||
|
||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||
let newActiveTabId = state.activeTabId;
|
||||
|
||||
// If closing the active tab, activate an adjacent tab
|
||||
if (state.activeTabId === id) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveTabId = null;
|
||||
} else if (tabIndex < newTabs.length) {
|
||||
// Activate the tab that moved into this position (next tab)
|
||||
newActiveTabId = newTabs[tabIndex].id;
|
||||
} else {
|
||||
// Activate the previous tab
|
||||
newActiveTabId = newTabs[newTabs.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveTabId };
|
||||
}),
|
||||
|
||||
setActiveTab: (id) => set((state) => {
|
||||
// Only set if the tab exists
|
||||
const tabExists = state.tabs.some((t) => t.id === id);
|
||||
if (!tabExists) return state;
|
||||
return { activeTabId: id };
|
||||
}),
|
||||
|
||||
pinTab: (id) => set((state) => ({
|
||||
tabs: state.tabs.map((t) => (t.id === id ? { ...t, isTransient: false } : t)),
|
||||
})),
|
||||
|
||||
clearTabs: () => set({ tabs: [], activeTabId: null }),
|
||||
|
||||
getTabState: () => {
|
||||
const state = get();
|
||||
return { tabs: state.tabs, activeTabId: state.activeTabId };
|
||||
},
|
||||
|
||||
restoreTabState: (tabState) => set({
|
||||
tabs: tabState.tabs,
|
||||
activeTabId: tabState.activeTabId
|
||||
}),
|
||||
|
||||
// UI Actions
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||
@@ -283,15 +390,20 @@ export const useAppStore = create<AppState>()(
|
||||
selectedPostId: state.selectedPostId,
|
||||
selectedMediaId: state.selectedMediaId,
|
||||
preferredEditorMode: state.preferredEditorMode,
|
||||
// Tabs are persisted here for now (project-specific persistence handled separately)
|
||||
tabs: state.tabs,
|
||||
activeTabId: state.activeTabId,
|
||||
// Convert Set to array for storage
|
||||
dirtyPosts: [...state.dirtyPosts],
|
||||
}),
|
||||
// Merge function to restore Set from array
|
||||
merge: (persisted, current) => {
|
||||
const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[] };
|
||||
const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[]; tabs?: Tab[] };
|
||||
return {
|
||||
...current,
|
||||
...persistedState,
|
||||
tabs: persistedState.tabs || [],
|
||||
activeTabId: persistedState.activeTabId || null,
|
||||
dirtyPosts: new Set(persistedState.dirtyPosts || []),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,5 +5,8 @@ export {
|
||||
type MediaData,
|
||||
type TaskProgress,
|
||||
type EditorMode,
|
||||
type ErrorDetails
|
||||
type ErrorDetails,
|
||||
type Tab,
|
||||
type TabType,
|
||||
type TabState
|
||||
} from './appStore';
|
||||
|
||||
129
src/renderer/utils/autoSave.ts
Normal file
129
src/renderer/utils/autoSave.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* AutoSaveManager - handles automatic saving of drafts with idle detection
|
||||
*
|
||||
* This manager tracks changes to multiple items and saves them after a configurable
|
||||
* idle period. Changes are accumulated and merged before saving.
|
||||
*/
|
||||
|
||||
export interface AutoSaveConfig {
|
||||
/** Time in milliseconds to wait after last change before saving (default: 3000) */
|
||||
idleTimeMs: number;
|
||||
/** Callback to perform the save operation */
|
||||
onSave: (id: string, changes: Record<string, unknown>) => Promise<void>;
|
||||
/** Callback when save completes successfully */
|
||||
onSaveComplete?: (id: string) => void;
|
||||
/** Callback when save fails */
|
||||
onSaveError?: (id: string, error: Error) => void;
|
||||
}
|
||||
|
||||
interface PendingChange {
|
||||
changes: Record<string, unknown>;
|
||||
timerId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class AutoSaveManager {
|
||||
private pendingChanges: Map<string, PendingChange> = new Map();
|
||||
private config: AutoSaveConfig;
|
||||
private disposed = false;
|
||||
|
||||
constructor(config: AutoSaveConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the manager of a change to an item.
|
||||
* Resets the idle timer and accumulates the changes.
|
||||
*/
|
||||
notifyChange(id: string, changes: Record<string, unknown>): void {
|
||||
if (this.disposed) return;
|
||||
|
||||
const existing = this.pendingChanges.get(id);
|
||||
|
||||
// Clear existing timer if any
|
||||
if (existing) {
|
||||
clearTimeout(existing.timerId);
|
||||
}
|
||||
|
||||
// Merge changes with existing pending changes
|
||||
const mergedChanges = {
|
||||
...(existing?.changes || {}),
|
||||
...changes,
|
||||
};
|
||||
|
||||
// Set new timer
|
||||
const timerId = setTimeout(() => {
|
||||
this.performSave(id);
|
||||
}, this.config.idleTimeMs);
|
||||
|
||||
this.pendingChanges.set(id, {
|
||||
changes: mergedChanges,
|
||||
timerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the save operation for a specific item.
|
||||
*/
|
||||
private async performSave(id: string): Promise<void> {
|
||||
const pending = this.pendingChanges.get(id);
|
||||
if (!pending) return;
|
||||
|
||||
// Remove from pending before saving
|
||||
this.pendingChanges.delete(id);
|
||||
|
||||
try {
|
||||
await this.config.onSave(id, pending.changes);
|
||||
this.config.onSaveComplete?.(id);
|
||||
} catch (error) {
|
||||
this.config.onSaveError?.(id, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save all pending changes immediately.
|
||||
*/
|
||||
async forceSave(): Promise<void> {
|
||||
const ids = Array.from(this.pendingChanges.keys());
|
||||
|
||||
// Cancel all timers
|
||||
for (const pending of this.pendingChanges.values()) {
|
||||
clearTimeout(pending.timerId);
|
||||
}
|
||||
|
||||
// Save all pending changes in parallel
|
||||
await Promise.all(ids.map((id) => this.performSave(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending save for a specific item.
|
||||
*/
|
||||
cancel(id: string): void {
|
||||
const pending = this.pendingChanges.get(id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timerId);
|
||||
this.pendingChanges.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any pending changes.
|
||||
* If id is provided, checks only for that specific item.
|
||||
*/
|
||||
hasPendingChanges(id?: string): boolean {
|
||||
if (id) {
|
||||
return this.pendingChanges.has(id);
|
||||
}
|
||||
return this.pendingChanges.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the manager, canceling all pending saves.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
for (const pending of this.pendingChanges.values()) {
|
||||
clearTimeout(pending.timerId);
|
||||
}
|
||||
this.pendingChanges.clear();
|
||||
}
|
||||
}
|
||||
1
src/renderer/utils/index.ts
Normal file
1
src/renderer/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
||||
Reference in New Issue
Block a user