fix: better handling of deletes and links

This commit is contained in:
2026-02-13 10:19:43 +01:00
parent b61dfd7b61
commit f904f42f88
13 changed files with 728 additions and 45 deletions

View File

@@ -507,7 +507,7 @@ export class MediaEngine extends EventEmitter {
async deleteMedia(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(media).where(eq(media.id, id)).get();
if (!existing) {
return false;
}
@@ -529,6 +529,10 @@ export class MediaEngine extends EventEmitter {
// Delete thumbnails
await this.deleteThumbnails(id);
// Delete post-media links (cascade cleanup)
const { postMedia } = await import('../database/schema');
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
await db.delete(media).where(eq(media.id, id));
this.emit('mediaDeleted', id);

View File

@@ -451,7 +451,7 @@ export class PostEngine extends EventEmitter {
const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!existing) {
return false;
}
@@ -469,6 +469,25 @@ export class PostEngine extends EventEmitter {
await db.delete(postLinks).where(eq(postLinks.sourcePostId, id));
await db.delete(postLinks).where(eq(postLinks.targetPostId, id));
// Delete post-media links and update media sidecars
const { postMedia } = await import('../database/schema');
const { getMediaEngine } = await import('./MediaEngine');
const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id));
const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : [];
// Remove this post from each linked media's sidecar
const mediaEngine = getMediaEngine();
for (const link of linkedMedia) {
const media = await mediaEngine.getMedia(link.mediaId);
if (media && media.linkedPostIds) {
const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id);
await mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds });
}
}
// Delete post-media junction entries
await db.delete(postMedia).where(eq(postMedia.postId, id));
// Delete from database
await db.delete(posts).where(eq(posts.id, id));

View File

@@ -0,0 +1,172 @@
.confirm-delete-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.confirm-delete-modal {
background: var(--color-bg-secondary, #1e1e1e);
border: 1px solid var(--color-border, #3c3c3c);
border-radius: 8px;
min-width: 450px;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.confirm-delete-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border, #3c3c3c);
}
.confirm-delete-modal-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text, #fff);
}
.confirm-delete-modal-close {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
}
.confirm-delete-modal-close:hover {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text, #fff);
}
.confirm-delete-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.confirm-delete-message {
font-size: 14px;
line-height: 1.5;
color: var(--color-text, #ccc);
margin-bottom: 16px;
}
.confirm-delete-message strong {
color: var(--color-text, #fff);
font-weight: 600;
}
.confirm-delete-warning {
display: flex;
gap: 12px;
background: rgba(255, 165, 0, 0.1);
border: 1px solid rgba(255, 165, 0, 0.3);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
}
.warning-icon {
font-size: 20px;
flex-shrink: 0;
}
.warning-content {
flex: 1;
font-size: 13px;
color: var(--color-text, #ccc);
}
.warning-content strong {
color: #ffa500;
font-weight: 600;
}
.reference-list {
margin: 12px 0;
padding-left: 0;
list-style: none;
max-height: 200px;
overflow-y: auto;
}
.reference-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--color-bg-primary, #0d0d0d);
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
.reference-type {
font-size: 16px;
flex-shrink: 0;
}
.reference-title {
color: var(--color-text, #ccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.warning-note {
margin: 8px 0 0 0;
font-size: 12px;
color: var(--color-text-muted, #888);
}
.confirm-delete-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border, #3c3c3c);
}
.confirm-delete-modal-footer button {
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.button-cancel {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text, #ccc);
border: 1px solid var(--color-border, #3c3c3c);
}
.button-cancel:hover {
background: var(--color-bg-hover, #333);
color: var(--color-text, #fff);
}
.button-delete {
background: var(--color-error, #f14c4c);
color: #fff;
}
.button-delete:hover {
background: #d43f3f;
}

View File

@@ -0,0 +1,86 @@
import React, { useCallback } from 'react';
import './ConfirmDeleteModal.css';
export interface DeleteReference {
id: string;
title: string;
type: 'post' | 'media' | 'link';
}
export interface ConfirmDeleteDetails {
itemType: 'post' | 'media';
itemTitle: string;
references: DeleteReference[];
onConfirm: () => void | Promise<void>;
}
interface ConfirmDeleteModalProps {
details: ConfirmDeleteDetails | null;
onClose: () => void;
}
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
if (!details) return null;
const handleConfirm = useCallback(async () => {
await details.onConfirm();
onClose();
}, [details, onClose]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
const hasReferences = details.references.length > 0;
return (
<div className="confirm-delete-modal-backdrop" onClick={handleBackdropClick}>
<div className="confirm-delete-modal">
<div className="confirm-delete-modal-header">
<h2>Confirm Deletion</h2>
<button className="confirm-delete-modal-close" onClick={onClose} title="Close">
</button>
</div>
<div className="confirm-delete-modal-body">
<div className="confirm-delete-message">
Are you sure you want to delete {details.itemType === 'post' ? 'the post' : 'the media file'}{' '}
<strong>{details.itemTitle}</strong>?
</div>
{hasReferences && (
<div className="confirm-delete-warning">
<div className="warning-icon"></div>
<div className="warning-content">
<strong>Warning:</strong> This {details.itemType} is referenced by the following items:
<ul className="reference-list">
{details.references.map((ref) => (
<li key={ref.id}>
<span className="reference-type">
{ref.type === 'post' ? '📄' : ref.type === 'media' ? '🖼️' : '🔗'}
</span>
<span className="reference-title">{ref.title}</span>
</li>
))}
</ul>
<p className="warning-note">
Deleting this {details.itemType} will remove all these references.
</p>
</div>
</div>
)}
</div>
<div className="confirm-delete-modal-footer">
<button className="button-cancel" onClick={onClose}>
Cancel
</button>
<button className="button-delete" onClick={handleConfirm}>
Delete {details.itemType === 'post' ? 'Post' : 'Media'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { ConfirmDeleteModal } from './ConfirmDeleteModal';
export type { DeleteReference, ConfirmDeleteDetails } from './ConfirmDeleteModal';

View File

@@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks';
import { LinkedMediaPanel } from '../LinkedMediaPanel';
import { ErrorModal } from '../ErrorModal';
import { ConfirmDeleteModal } from '../ConfirmDeleteModal';
import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput';
@@ -240,14 +241,15 @@ interface PostEditorProps {
}
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const {
updatePost,
markDirty,
markClean,
const {
updatePost,
markDirty,
markClean,
isDirty: checkIsDirty,
preferredEditorMode,
setPreferredEditorMode,
showErrorModal,
showConfirmDeleteModal,
media,
} = useAppStore();
@@ -510,23 +512,69 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await window.electronAPI?.posts.delete(post.id);
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
pendingChangesRef.current = null;
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted');
} catch (error) {
console.error('Failed to delete post:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete post',
stack: err.stack,
try {
// Fetch references to this post
const [linkedBy, linkedMedia] = await Promise.all([
window.electronAPI?.posts.getLinkedBy(post.id),
window.electronAPI?.postMedia.getForPost(post.id),
]);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that link to this post
if (linkedBy && linkedBy.length > 0) {
linkedBy.forEach((p: { id: string; title: string }) => {
references.push({ id: p.id, title: p.title, type: 'link' });
});
}
// Add linked media
if (linkedMedia && linkedMedia.length > 0) {
linkedMedia.forEach((m: { mediaId: string }) => {
const mediaItem = media.find(item => item.id === m.mediaId);
if (mediaItem) {
references.push({
id: mediaItem.id,
title: mediaItem.originalName,
type: 'media',
});
}
});
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'post',
itemTitle: post.title || 'Untitled',
references,
onConfirm: async () => {
try {
await window.electronAPI?.posts.delete(post.id);
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
pendingChangesRef.current = null;
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted');
} catch (error) {
console.error('Failed to delete post:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete post',
stack: err.stack,
});
}
},
});
} catch (error) {
console.error('Failed to fetch post references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch post references',
stack: err.stack,
});
}
};
@@ -879,7 +927,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, posts, updateMedia, showErrorModal, openTab } = useAppStore();
const { media, posts, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const item = media.find(m => m.id === mediaId);
const [alt, setAlt] = useState(item?.alt || '');
@@ -984,20 +1032,56 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this media file?')) {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success('Media deleted');
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete media',
stack: err.stack,
try {
// Fetch posts that link to this media
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that use this media
if (linkedPostsList && linkedPostsList.length > 0) {
linkedPostsList.forEach((link: { postId: string }) => {
const post = posts.find(p => p.id === link.postId);
if (post) {
references.push({
id: post.id,
title: post.title || 'Untitled',
type: 'post',
});
}
});
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'media',
itemTitle: item.originalName,
references,
onConfirm: async () => {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success('Media deleted');
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete media',
stack: err.stack,
});
}
},
});
} catch (error) {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch media references',
stack: err.stack,
});
}
};
@@ -1375,16 +1459,18 @@ const Dashboard: React.FC = () => {
};
export const Editor: React.FC = () => {
const {
activeView,
selectedPostId,
const {
activeView,
selectedPostId,
selectedMediaId,
tabs,
activeTabId,
posts,
posts,
media,
errorModal,
hideErrorModal,
confirmDeleteModal,
hideConfirmDeleteModal,
isLoading,
setSelectedPost,
setSelectedMedia,
@@ -1450,12 +1536,18 @@ export const Editor: React.FC = () => {
<ErrorModal error={errorModal} onClose={hideErrorModal} />
);
// Show confirm delete modal if present
const renderConfirmDeleteModal = () => (
<ConfirmDeleteModal details={confirmDeleteModal} onClose={hideConfirmDeleteModal} />
);
// Show settings if settings tab is active or settings view with no tab
if (showSettings) {
return (
<div className="editor">
<SettingsView />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
@@ -1466,6 +1558,7 @@ export const Editor: React.FC = () => {
<div className="editor">
<TagsView />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
@@ -1476,6 +1569,7 @@ export const Editor: React.FC = () => {
<div className="editor">
<ChatPanel key={activeTabId} conversationId={activeTabId} />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
@@ -1488,10 +1582,11 @@ export const Editor: React.FC = () => {
<div className="editor">
<PostEditor key={post.id} post={post} />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
// Post not found - show loading or empty state
return (
<div className="editor">
@@ -1501,6 +1596,7 @@ export const Editor: React.FC = () => {
</div>
</div>
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
@@ -1511,6 +1607,7 @@ export const Editor: React.FC = () => {
<div className="editor">
<MediaEditor key={activeTabId} mediaId={activeTabId} />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
}
@@ -1520,6 +1617,7 @@ export const Editor: React.FC = () => {
<div className="editor">
<Dashboard />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store';
import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron';
import './Sidebar.css';

View File

@@ -17,4 +17,5 @@ export { TagInput } from './TagInput';
export { PostLinks } from './PostLinks';
export { LinkedMediaPanel } from './LinkedMediaPanel';
export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
export { ChatPanel } from './ChatPanel';

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { DeleteReference, ConfirmDeleteDetails } from '../components/ConfirmDeleteModal';
// Storage key for persisted state
const STORAGE_KEY = 'bds-app-state';
@@ -76,6 +77,9 @@ export interface ErrorDetails {
stack?: string;
}
// Re-export types from ConfirmDeleteModal for convenience
export type { DeleteReference, ConfirmDeleteDetails };
export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
// App State Store
@@ -110,7 +114,10 @@ interface AppState {
// Error modal
errorModal: ErrorDetails | null;
// Confirm delete modal
confirmDeleteModal: ConfirmDeleteDetails | null;
// Sync
syncStatus: 'idle' | 'syncing' | 'error';
syncConfigured: boolean;
@@ -158,7 +165,11 @@ interface AppState {
// Error modal actions
showErrorModal: (error: ErrorDetails) => void;
hideErrorModal: () => void;
// Confirm delete modal actions
showConfirmDeleteModal: (details: ConfirmDeleteDetails) => void;
hideConfirmDeleteModal: () => void;
setMedia: (media: MediaData[]) => void;
addMedia: (media: MediaData) => void;
updateMedia: (id: string, media: Partial<MediaData>) => void;
@@ -208,7 +219,10 @@ export const useAppStore = create<AppState>()(
// Error modal
errorModal: null,
// Confirm delete modal
confirmDeleteModal: null,
// Initial Sync State
syncStatus: 'idle',
syncConfigured: false,
@@ -356,7 +370,11 @@ export const useAppStore = create<AppState>()(
// Error modal actions
showErrorModal: (error) => set({ errorModal: error }),
hideErrorModal: () => set({ errorModal: null }),
// Confirm delete modal actions
showConfirmDeleteModal: (details) => set({ confirmDeleteModal: details }),
hideConfirmDeleteModal: () => set({ confirmDeleteModal: null }),
// Media Actions
setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),

View File

@@ -66,6 +66,19 @@ export interface MediaData {
tags: string[];
}
export interface MediaFilter {
tags?: string[];
year?: number;
month?: number;
}
export interface MediaSearchResult {
id: string;
originalName: string;
mimeType: string;
createdAt: string;
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
@@ -291,6 +304,11 @@ export interface ElectronAPI {
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>;
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
filter: (filter: MediaFilter) => Promise<MediaData[]>;
search: (query: string) => Promise<MediaSearchResult[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getTags: () => Promise<string[]>;
getTagsWithCounts: () => Promise<TagCount[]>;
};
postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;