feat: project deletion

This commit is contained in:
2026-02-11 09:13:26 +01:00
parent 4e2f6d4d08
commit 4da195c89a
11 changed files with 477 additions and 51 deletions

View File

@@ -21,7 +21,7 @@ export const CredentialsPanel: React.FC = () => {
sshKeyPath: '',
});
const [activeTab, setActiveTab] = useState<'ftp' | 'ssh'>('ftp');
const [showTokens, setShowTokens] = useState(false);
const [showTokens, _setShowTokens] = useState(false);
// Load saved credentials (in a real app, use secure storage)
useEffect(() => {

View File

@@ -71,29 +71,6 @@
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--vscode-dropdown-foreground);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.project-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.project-item-name {
flex: 1;
overflow: hidden;
@@ -273,3 +250,108 @@
.btn-secondary:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.btn-danger {
padding: 6px 14px;
background-color: var(--vscode-inputValidation-errorBackground, #5a1d1d);
color: var(--vscode-inputValidation-errorForeground, #f48771);
border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-danger:hover:not(:disabled) {
background-color: #6b2222;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Project item with delete button */
.project-item {
display: flex;
align-items: center;
width: 100%;
background: transparent;
border: none;
}
.project-item-content {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--vscode-dropdown-foreground);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.project-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active {
background-color: var(--vscode-list-activeSelectionBackground);
}
.project-item.active .project-item-content {
color: var(--vscode-list-activeSelectionForeground);
}
.project-item-delete {
display: none;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
opacity: 0.6;
}
.project-item:hover .project-item-delete {
display: flex;
}
.project-item-delete:hover {
opacity: 1;
color: var(--vscode-errorForeground, #f48771);
}
/* Delete modal styles */
.modal-danger .modal-header h3 {
color: var(--vscode-errorForeground, #f48771);
}
.delete-warning {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--vscode-foreground);
line-height: 1.5;
}
.delete-list {
margin: 0 0 16px 0;
padding-left: 20px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
line-height: 1.6;
}
.delete-list li {
margin: 4px 0;
}
.delete-confirm-text {
margin: 0 0 8px 0;
font-size: 12px;
color: var(--vscode-foreground);
}

View File

@@ -1,12 +1,15 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore, ProjectData } from '../../store';
import { useAppStore, ProjectData, PostData, MediaData } from '../../store';
import { showToast } from '../Toast';
import './ProjectSelector.css';
export const ProjectSelector: React.FC = () => {
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia } = useAppStore();
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia, removeProject } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<ProjectData | null>(null);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -56,12 +59,16 @@ export const ProjectSelector: React.FC = () => {
setSelectedMedia(null);
// Reload posts and media for new project
const [posts, media] = await Promise.all([
window.electronAPI?.posts.getAll(),
const [postsResult, mediaResult] = await Promise.all([
window.electronAPI?.posts.getAll({ limit: 500, offset: 0 }),
window.electronAPI?.media.getAll(),
]);
if (posts) setPosts(posts);
if (media) setMedia(media);
// posts.getAll returns { items, hasMore, total }
if (postsResult) {
const { items, hasMore, total } = postsResult as { items: PostData[]; hasMore: boolean; total: number };
setPosts(items, hasMore, total);
}
if (mediaResult) setMedia(mediaResult as MediaData[]);
showToast.success(`Switched to ${project.name}`);
}
@@ -97,6 +104,40 @@ export const ProjectSelector: React.FC = () => {
}
};
const openDeleteModal = (e: React.MouseEvent, project: ProjectData) => {
e.stopPropagation();
setProjectToDelete(project);
setDeleteConfirmText('');
setShowDeleteModal(true);
setIsOpen(false);
};
const handleDeleteProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!projectToDelete || deleteConfirmText !== projectToDelete.name) return;
try {
const success = await window.electronAPI?.projects.deleteWithData(projectToDelete.id);
if (success) {
removeProject(projectToDelete.id);
showToast.success(`Deleted project "${projectToDelete.name}" and all its data`);
setShowDeleteModal(false);
setProjectToDelete(null);
setDeleteConfirmText('');
} else {
showToast.error('Failed to delete project');
}
} catch (error) {
console.error('Failed to delete project:', error);
showToast.error('Failed to delete project');
}
};
const canDeleteProject = (project: ProjectData) => {
// Cannot delete: default project, or the currently active project
return project.id !== 'default' && project.id !== activeProject?.id;
};
return (
<div className="project-selector" ref={dropdownRef}>
<button
@@ -120,18 +161,34 @@ export const ProjectSelector: React.FC = () => {
</div>
<div className="project-list">
{projects.map(project => (
<button
<div
key={project.id}
className={`project-item ${project.id === activeProject?.id ? 'active' : ''}`}
onClick={() => handleSwitchProject(project)}
>
<span className="project-item-name">{project.name}</span>
{project.id === activeProject?.id && (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="check-icon">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
<button
className="project-item-content"
onClick={() => handleSwitchProject(project)}
>
<span className="project-item-name">{project.name}</span>
{project.id === activeProject?.id && (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="check-icon">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
)}
</button>
{canDeleteProject(project) && (
<button
className="project-item-delete"
onClick={(e) => openDeleteModal(e, project)}
title={`Delete ${project.name}`}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 01-1-1V2a1 1 0 011-1h4a1 1 0 011 1h2a1 1 0 011 1h2.5a1 1 0 011 1v1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
)}
</button>
</div>
))}
{projects.length === 0 && (
<div className="project-empty">No projects yet</div>
@@ -195,6 +252,57 @@ export const ProjectSelector: React.FC = () => {
</div>
</div>
)}
{showDeleteModal && projectToDelete && (
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
<div className="modal-content modal-danger" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>Delete Project</h3>
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path 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>
</button>
</div>
<form onSubmit={handleDeleteProject}>
<div className="modal-body">
<p className="delete-warning">
This will permanently delete the project <strong>"{projectToDelete.name}"</strong> and all its data including:
</p>
<ul className="delete-list">
<li>All blog posts</li>
<li>All media files</li>
<li>All project settings</li>
</ul>
<p className="delete-confirm-text">
Type <strong>{projectToDelete.name}</strong> to confirm deletion:
</p>
<div className="form-field">
<input
type="text"
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder={projectToDelete.name}
autoFocus
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowDeleteModal(false)}>
Cancel
</button>
<button
type="submit"
className="btn-danger"
disabled={deleteConfirmText !== projectToDelete.name}
>
Delete Project
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -97,7 +97,7 @@ const SettingSection: React.FC<{
};
export const SettingsView: React.FC = () => {
const { preferredEditorMode, setPreferredEditorMode, syncConfigured, activeProject, setActiveProject } = useAppStore();
const { preferredEditorMode, setPreferredEditorMode, activeProject, setActiveProject } = useAppStore();
const [searchQuery, setSearchQuery] = useState('');
const [credentials, setCredentials] = useState<Credentials>(defaultCredentials);
const [showSecrets, setShowSecrets] = useState(false);
@@ -113,12 +113,6 @@ export const SettingsView: React.FC = () => {
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
const [newCategoryInput, setNewCategoryInput] = useState('');
// Check if a setting matches the search query
const matchesSearch = useCallback((text: string) => {
if (!searchQuery) return true;
return text.toLowerCase().includes(searchQuery.toLowerCase());
}, [searchQuery]);
// Check if a section has any matching settings
const sectionHasMatches = useCallback((sectionKeywords: string[]) => {
if (!searchQuery) return true;
@@ -657,9 +651,9 @@ export const SettingsView: React.FC = () => {
showToast.loading('Rebuilding posts database...');
try {
await window.electronAPI?.posts.rebuildFromFiles();
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
useAppStore.getState().setPosts(posts as any[]);
const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 });
if (postsResult) {
useAppStore.getState().setPosts(postsResult.items, postsResult.hasMore, postsResult.total);
}
showToast.dismiss();
showToast.success('Posts database rebuilt');

View File

@@ -9,6 +9,13 @@ import Underline from '@tiptap/extension-underline';
import { Markdown } from 'tiptap-markdown';
import './WysiwygEditor.css';
// Type for tiptap-markdown extension storage (not exported by the package)
interface MarkdownStorage {
markdown: {
getMarkdown: () => string;
};
}
interface WysiwygEditorProps {
content: string;
onChange: (markdown: string) => void;
@@ -62,7 +69,7 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
return;
}
isInternalChange.current = true;
const markdown = editor.storage.markdown.getMarkdown();
const markdown = (editor.storage as unknown as MarkdownStorage).markdown.getMarkdown();
onChange(markdown);
},
editable: true,

View File

@@ -104,11 +104,35 @@ export interface DropboxConflict {
remoteModified: string;
}
export interface PaginatedPostsResult {
items: PostData[];
hasMore: boolean;
total: number;
}
export interface DashboardStats {
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
}
export interface TagCount {
tag: string;
count: number;
}
export interface CategoryCount {
category: string;
count: number;
}
export interface ElectronAPI {
projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
delete: (id: string) => Promise<boolean>;
deleteWithData: (id: string) => Promise<boolean>;
get: (id: string) => Promise<ProjectData | null>;
getAll: () => Promise<ProjectData[]>;
getActive: () => Promise<ProjectData | null>;
@@ -119,7 +143,7 @@ export interface ElectronAPI {
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getAll: () => Promise<PostData[]>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
@@ -131,6 +155,9 @@ export interface ElectronAPI {
getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getDashboardStats: () => Promise<DashboardStats>;
getTagsWithCounts: () => Promise<TagCount[]>;
getCategoriesWithCounts: () => Promise<CategoryCount[]>;
getLinksTo: (id: string) => Promise<PostData[]>;
getLinkedBy: (id: string) => Promise<PostData[]>;
rebuildLinks: () => Promise<void>;