Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 133 additions & 54 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Plus,
RefreshCw,
RotateCcw,
Trash2,
Triangle,
X,
} from "lucide-react";
Expand Down Expand Up @@ -133,6 +134,7 @@ interface OpenProjectModalProps extends BaseModalProps {
projects: ProjectItem[];
activeProjectId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onBrowse: () => void;
}

Expand All @@ -142,10 +144,19 @@ export function OpenProjectModal({
projects,
activeProjectId,
onSelect,
onDelete,
onBrowse,
}: OpenProjectModalProps) {
const t = useScopedT("editor");
const tc = useScopedT("common");
const [query, setQuery] = useState("");
// Deleting a project cannot be undone, so the trash icon arms an inline
// confirm on its own row instead of deleting on the first click. One row at a
// time, and closing the dialog disarms it.
const [confirmId, setConfirmId] = useState<string | null>(null);
useEffect(() => {
if (!open) setConfirmId(null);
}, [open]);
const filtered = projects.filter((p) => p.title.toLowerCase().includes(query.toLowerCase()));
return (
<ModalShell
Expand Down Expand Up @@ -197,74 +208,142 @@ export function OpenProjectModal({
) : (
filtered.map((p) => {
const isActive = p.id === activeProjectId;
return (
<button
type="button"
key={p.id}
onClick={() => {
onSelect(p.id);
onClose();
}}
style={{
display: "grid",
gridTemplateColumns: "36px 1fr auto",
alignItems: "center",
gap: 12,
padding: "10px 12px",
border: "none",
borderRadius: "var(--r-md)",
background: isActive ? "var(--accent-wash)" : "transparent",
boxShadow: isActive ? "inset 0 0 0 1px var(--accent)" : "none",
color: "var(--fg)",
cursor: "pointer",
textAlign: "left",
font: "inherit",
}}
>
if (p.id === confirmId) {
return (
<div
key={p.id}
style={{
width: 36,
height: 36,
borderRadius: "var(--r-sm)",
background: "linear-gradient(135deg, var(--brand-lo), var(--brand))",
display: "grid",
placeItems: "center",
color: "var(--accent-on)",
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderRadius: "var(--r-md)",
background: "var(--danger-soft)",
boxShadow: "inset 0 0 0 1px var(--danger-hatch)",
}}
>
<FolderOpen size={18} />
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
font: "500 13px/1.3 var(--font-body)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{p.title}
</div>
<div
style={{
font: "400 11px/1.4 var(--font-body)",
color: "var(--muted)",
marginTop: 2,
}}
>
{t("openProjectDialog.confirmDelete")}
</div>
</div>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={() => setConfirmId(null)}
>
{tc("actions.cancel")}
</button>
<button
type="button"
className={`${styles.btn} ${styles.dangerBtn}`}
onClick={() => {
setConfirmId(null);
onDelete(p.id);
}}
>
<Trash2 size={14} />
{tc("actions.delete")}
</button>
</div>
<div style={{ minWidth: 0 }}>
);
}
return (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
onClick={() => {
onSelect(p.id);
onClose();
}}
style={{
flex: 1,
minWidth: 0,
display: "grid",
gridTemplateColumns: "36px 1fr auto",
alignItems: "center",
gap: 12,
padding: "10px 12px",
border: "none",
borderRadius: "var(--r-md)",
background: isActive ? "var(--accent-wash)" : "transparent",
boxShadow: isActive ? "inset 0 0 0 1px var(--accent)" : "none",
color: "var(--fg)",
cursor: "pointer",
textAlign: "left",
font: "inherit",
}}
>
<div
style={{
font: "500 13px/1.3 var(--font-body)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
width: 36,
height: 36,
borderRadius: "var(--r-sm)",
background: "linear-gradient(135deg, var(--brand-lo), var(--brand))",
display: "grid",
placeItems: "center",
color: "var(--accent-on)",
}}
>
{p.title}
<FolderOpen size={18} />
</div>
<div
<div style={{ minWidth: 0 }}>
<div
style={{
font: "500 13px/1.3 var(--font-body)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{p.title}
</div>
<div
style={{
font: "400 11px/1.4 var(--font-mono)",
color: "var(--muted)",
marginTop: 2,
}}
>
id: {p.id.slice(0, 8)}
</div>
</div>
<span
style={{
font: "400 11px/1.4 var(--font-mono)",
color: "var(--muted)",
marginTop: 2,
font: "400 11px/1 var(--font-mono)",
color: "var(--meta)",
whiteSpace: "nowrap",
}}
>
id: {p.id.slice(0, 8)}
</div>
</div>
<span
style={{
font: "400 11px/1 var(--font-mono)",
color: "var(--meta)",
whiteSpace: "nowrap",
}}
{new Date(p.updatedAt).toLocaleDateString()}
</span>
</button>
<button
type="button"
className={styles.iconBtn}
onClick={() => setConfirmId(p.id)}
title={t("openProjectDialog.deleteProject")}
aria-label={t("openProjectDialog.deleteProject")}
>
{new Date(p.updatedAt).toLocaleDateString()}
</span>
</button>
<Trash2 size={14} />
</button>
</div>
);
})
)}
Expand Down
22 changes: 22 additions & 0 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,27 @@ export function NewEditorShell() {
[createProject],
);

// The only way to get rid of a project short of deleting its file by hand.
// Media is left alone on purpose: a recording can back several projects, and
// the dialog says so before it asks.
const handleDeleteProject = useCallback(async (id: string) => {
try {
const result = await nativeBridgeClient.aiEdition.delete(id);
if (!result.success) throw new Error(result.error ?? "Failed to delete project");
setProjectSummaries((prev) => prev.filter((p) => p.id !== id));
// Deleting the project that is open leaves the editor pointing at a file
// that no longer exists — any save from there would recreate it.
if (useProjectStore.getState().projectId === id) {
useProjectStore.getState().clear();
}
toast.success("Project deleted");
} catch (err) {
toast.error("Could not delete project", {
description: err instanceof Error ? err.message : String(err),
});
}
}, []);

const handleSave = useCallback(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
Expand Down Expand Up @@ -1271,6 +1292,7 @@ export function NewEditorShell() {
projects={projectSummaries}
activeProjectId={projectId}
onSelect={handleSelectProject}
onDelete={handleDeleteProject}
onBrowse={handleBrowseProject}
/>
<NewProjectModal
Expand Down
79 changes: 79 additions & 0 deletions src/components/ai-edition/OpenProjectModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/contexts/I18nContext";
import { OpenProjectModal } from "./Modals";

function renderWithI18n(ui: ReactElement) {
return render(<I18nProvider>{ui}</I18nProvider>);
}

const projects = [
{ id: "proj_one", title: "First project", updatedAt: "2026-08-12T10:00:00.000Z" },
{ id: "proj_two", title: "Second project", updatedAt: "2026-08-13T10:00:00.000Z" },
];

function renderModal(overrides: { onSelect?: () => void; onDelete?: () => void } = {}) {
const onSelect = overrides.onSelect ?? vi.fn();
const onDelete = overrides.onDelete ?? vi.fn();
renderWithI18n(
<OpenProjectModal
open={true}
onClose={vi.fn()}
projects={projects}
activeProjectId={null}
onSelect={onSelect}
onDelete={onDelete}
onBrowse={vi.fn()}
/>,
);
return { onSelect, onDelete };
}

describe("OpenProjectModal delete", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

it("asks before deleting instead of deleting on the first click", () => {
const { onDelete } = renderModal();

fireEvent.click(screen.getAllByRole("button", { name: /delete project/i })[0]);

expect(onDelete).not.toHaveBeenCalled();
expect(screen.getByText(/recordings are kept/i)).toBeInTheDocument();
});

it("deletes the project the confirm was armed on", () => {
const { onDelete } = renderModal();

fireEvent.click(screen.getAllByRole("button", { name: /delete project/i })[1]);
fireEvent.click(screen.getByRole("button", { name: /^delete$/i }));

expect(onDelete).toHaveBeenCalledWith("proj_two");
});

it("cancels back to the row", () => {
const { onDelete } = renderModal();

fireEvent.click(screen.getAllByRole("button", { name: /delete project/i })[0]);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));

expect(onDelete).not.toHaveBeenCalled();
expect(screen.getByText("First project")).toBeInTheDocument();
expect(screen.queryByText(/recordings are kept/i)).not.toBeInTheDocument();
});

// The delete button sits next to the row's own button, not inside it — a
// nested button would swallow the click that opens the project.
it("still opens a project when the row is clicked", () => {
const { onSelect } = renderModal();

fireEvent.click(screen.getByText("First project"));

expect(onSelect).toHaveBeenCalledWith("proj_one");
});
});
4 changes: 3 additions & 1 deletion src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@
"noMatches": "لا توجد مشاريع تطابق \"{{query}}\".",
"navigateHint": "للتنقل ·",
"openHint": "للفتح",
"browseFiles": "تصفح الملفات…"
"browseFiles": "تصفح الملفات…",
"deleteProject": "حذف المشروع",
"confirmDelete": "هل تريد حذف هذا المشروع؟ سيتم الاحتفاظ بتسجيلاتك."
},
"editClipDialog": {
"title": "تعديل المقطع",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@
"noMatches": "No projects match \"{{query}}\".",
"navigateHint": "to navigate ·",
"openHint": "to open",
"browseFiles": "Browse files…"
"browseFiles": "Browse files…",
"deleteProject": "Delete project",
"confirmDelete": "Delete this project? Your recordings are kept."
},
"editClipDialog": {
"title": "Edit clip",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@
"noMatches": "Ningún proyecto coincide con \"{{query}}\".",
"navigateHint": "para navegar ·",
"openHint": "para abrir",
"browseFiles": "Explorar archivos…"
"browseFiles": "Explorar archivos…",
"deleteProject": "Eliminar proyecto",
"confirmDelete": "¿Eliminar este proyecto? Tus grabaciones se conservan."
},
"editClipDialog": {
"title": "Editar clip",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@
"noMatches": "Aucun projet ne correspond à « {{query}} ».",
"navigateHint": "pour naviguer ·",
"openHint": "pour ouvrir",
"browseFiles": "Parcourir les fichiers…"
"browseFiles": "Parcourir les fichiers…",
"deleteProject": "Supprimer le projet",
"confirmDelete": "Supprimer ce projet ? Vos enregistrements sont conservés."
},
"editClipDialog": {
"title": "Modifier le clip",
Expand Down
Loading
Loading