feat: proper tab handling

This commit is contained in:
2026-02-11 11:40:53 +01:00
parent 513ade1624
commit 01202d55cf
15 changed files with 1443 additions and 26 deletions

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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/') ? (

View 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;
}

View 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;

View File

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

View File

@@ -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';

View File

@@ -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 || []),
};
},

View File

@@ -5,5 +5,8 @@ export {
type MediaData,
type TaskProgress,
type EditorMode,
type ErrorDetails
type ErrorDetails,
type Tab,
type TabType,
type TabState
} from './appStore';

View 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();
}
}

View File

@@ -0,0 +1 @@
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';