diff --git a/.gitignore b/.gitignore index 12e805e..34c7517 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ temp/ *.tmp *.temp *.bak +tmpclaude-* # User data (created at runtime) userData/ diff --git a/VISION.md b/VISION.md index 97036a6..87f81c2 100644 --- a/VISION.md +++ b/VISION.md @@ -25,7 +25,7 @@ metadata can always be reconstructed from posts. Do the same with images, keepin user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts. -The application must be offline-first, everything must work in airplane mode (except exporting of course). +The application must be offline-first, everything must work in airplane mode (except exporting / AI of course). It must be fully self-contained during editing and previewing and managing content. Every internal structure must have reflections in the filesystem, so available tags, available categories, all those things must be automatically reflected to the filesystem in a per-project way. Use a meta/ folder under the project folder diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 1958c49..893934a 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -507,7 +507,7 @@ export class MediaEngine extends EventEmitter { async deleteMedia(id: string): Promise { 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); diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 3323e94..f883ebb 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -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)); diff --git a/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.css b/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.css new file mode 100644 index 0000000..7b3a243 --- /dev/null +++ b/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.css @@ -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; +} diff --git a/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx b/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx new file mode 100644 index 0000000..a7892a4 --- /dev/null +++ b/src/renderer/components/ConfirmDeleteModal/ConfirmDeleteModal.tsx @@ -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; +} + +interface ConfirmDeleteModalProps { + details: ConfirmDeleteDetails | null; + onClose: () => void; +} + +export const ConfirmDeleteModal: React.FC = ({ 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 ( +
+
+
+

Confirm Deletion

+ +
+
+
+ Are you sure you want to delete {details.itemType === 'post' ? 'the post' : 'the media file'}{' '} + {details.itemTitle}? +
+ + {hasReferences && ( +
+
⚠️
+
+ Warning: This {details.itemType} is referenced by the following items: +
    + {details.references.map((ref) => ( +
  • + + {ref.type === 'post' ? '📄' : ref.type === 'media' ? '🖼️' : '🔗'} + + {ref.title} +
  • + ))} +
+

+ Deleting this {details.itemType} will remove all these references. +

+
+
+ )} +
+
+ + +
+
+
+ ); +}; diff --git a/src/renderer/components/ConfirmDeleteModal/index.ts b/src/renderer/components/ConfirmDeleteModal/index.ts new file mode 100644 index 0000000..f8a9303 --- /dev/null +++ b/src/renderer/components/ConfirmDeleteModal/index.ts @@ -0,0 +1,2 @@ +export { ConfirmDeleteModal } from './ConfirmDeleteModal'; +export type { DeleteReference, ConfirmDeleteDetails } from './ConfirmDeleteModal'; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 0a8b2c0..711f5bb 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = () => { ); + // Show confirm delete modal if present + const renderConfirmDeleteModal = () => ( + + ); + // Show settings if settings tab is active or settings view with no tab if (showSettings) { return (
{renderErrorModal()} + {renderConfirmDeleteModal()}
); } @@ -1466,6 +1558,7 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()}
); } @@ -1476,6 +1569,7 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()}
); } @@ -1488,10 +1582,11 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()}
); } - + // Post not found - show loading or empty state return (
@@ -1501,6 +1596,7 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()} ); } @@ -1511,6 +1607,7 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()}
); } @@ -1520,6 +1617,7 @@ export const Editor: React.FC = () => {
{renderErrorModal()} + {renderConfirmDeleteModal()}
); }; diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 76b1c2f..7073d96 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -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'; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 62becec..e9f727b 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -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'; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 4f1ff66..670ce90 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -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) => void; @@ -208,7 +219,10 @@ export const useAppStore = create()( // Error modal errorModal: null, - + + // Confirm delete modal + confirmDeleteModal: null, + // Initial Sync State syncStatus: 'idle', syncConfigured: false, @@ -356,7 +370,11 @@ export const useAppStore = create()( // 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] })), diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 5cf0e0b..8c9ca1b 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -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; regenerateThumbnails: (id: string) => Promise | null>; regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>; + filter: (filter: MediaFilter) => Promise; + search: (query: string) => Promise; + getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>; + getTags: () => Promise; + getTagsWithCounts: () => Promise; }; postMedia: { link: (postId: string, mediaId: string) => Promise; diff --git a/tests/renderer/components/ConfirmDeleteModal.test.tsx b/tests/renderer/components/ConfirmDeleteModal.test.tsx new file mode 100644 index 0000000..41b1ef6 --- /dev/null +++ b/tests/renderer/components/ConfirmDeleteModal.test.tsx @@ -0,0 +1,264 @@ +/** + * ConfirmDeleteModal Component Tests + * + * Tests the REAL ConfirmDeleteModal component with mocked callbacks. + * Following TDD best practices: verify component behavior. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConfirmDeleteModal, type ConfirmDeleteDetails } from '../../../src/renderer/components/ConfirmDeleteModal'; + +describe('ConfirmDeleteModal', () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should not render when details is null', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render modal when details is provided', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText('Confirm Deletion')).toBeInTheDocument(); + expect(screen.getByText(/Test Post/)).toBeInTheDocument(); + }); + + it('should display post deletion message for post type', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'My Blog Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText(/delete the post/)).toBeInTheDocument(); + expect(screen.getByText('My Blog Post')).toBeInTheDocument(); + }); + + it('should display media deletion message for media type', () => { + const details: ConfirmDeleteDetails = { + itemType: 'media', + itemTitle: 'image.jpg', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText(/delete the media file/)).toBeInTheDocument(); + expect(screen.getByText('image.jpg')).toBeInTheDocument(); + }); + }); + + describe('References Warning', () => { + it('should not show warning when no references exist', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.queryByText(/Warning/)).not.toBeInTheDocument(); + }); + + it('should show warning when references exist', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [ + { id: '1', title: 'Linked Post', type: 'link' }, + ], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText(/Warning/)).toBeInTheDocument(); + expect(screen.getByText('Linked Post')).toBeInTheDocument(); + }); + + it('should list all reference types correctly', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [ + { id: '1', title: 'Some Post', type: 'post' }, + { id: '2', title: 'Some Image', type: 'media' }, + { id: '3', title: 'Some Link', type: 'link' }, + ], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText('Some Post')).toBeInTheDocument(); + expect(screen.getByText('Some Image')).toBeInTheDocument(); + expect(screen.getByText('Some Link')).toBeInTheDocument(); + }); + + it('should show correct warning note', () => { + const details: ConfirmDeleteDetails = { + itemType: 'media', + itemTitle: 'photo.jpg', + references: [ + { id: '1', title: 'Blog Post', type: 'post' }, + ], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText(/Deleting this media will remove all these references/)).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('should call onClose when Cancel button is clicked', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + fireEvent.click(screen.getByText('Cancel')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when close button is clicked', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + fireEvent.click(screen.getByTitle('Close')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when backdrop is clicked', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + const backdrop = document.querySelector('.confirm-delete-modal-backdrop'); + fireEvent.click(backdrop!); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should not call onClose when modal content is clicked', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + const modal = document.querySelector('.confirm-delete-modal'); + fireEvent.click(modal!); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should call onConfirm and onClose when Delete button is clicked', async () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + fireEvent.click(screen.getByText('Delete Post')); + + // Wait for the async handler to complete + await vi.waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should show correct delete button text for post', () => { + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText('Delete Post')).toBeInTheDocument(); + }); + + it('should show correct delete button text for media', () => { + const details: ConfirmDeleteDetails = { + itemType: 'media', + itemTitle: 'image.jpg', + references: [], + onConfirm: mockOnConfirm, + }; + + render(); + + expect(screen.getByText('Delete Media')).toBeInTheDocument(); + }); + + it('should handle async onConfirm correctly', async () => { + const asyncConfirm = vi.fn().mockResolvedValue(undefined); + const details: ConfirmDeleteDetails = { + itemType: 'post', + itemTitle: 'Test Post', + references: [], + onConfirm: asyncConfirm, + }; + + render(); + + fireEvent.click(screen.getByText('Delete Post')); + + // Wait for the async handler to complete + await vi.waitFor(() => { + expect(asyncConfirm).toHaveBeenCalledTimes(1); + }); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); +});