Files
textDB/src/App.tsx

1842 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { Menu } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { appDataDir } from "@tauri-apps/api/path";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { Compartment, EditorState, Transaction, Prec } from "@codemirror/state";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLineGutter
} from "@codemirror/view";
import { history, historyKeymap } from "@codemirror/history";
import { defaultKeymap } from "@codemirror/commands";
import "@codemirror/view/style.css";
import folderIcon from "../src-tauri/icons/folder.png";
import folderIconBright from "../src-tauri/icons/folder_b.png";
import historyIcon from "../src-tauri/icons/history.png";
import historyIconBright from "../src-tauri/icons/history_b.png";
import newFolderIcon from "../src-tauri/icons/new_folder.png";
import newFolderIconBright from "../src-tauri/icons/new_folder_b.png";
import newTextIcon from "../src-tauri/icons/new_text.png";
import newTextIconBright from "../src-tauri/icons/new_text_b.png";
import openFileIcon from "../src-tauri/icons/open_file.png";
import openFileIconBright from "../src-tauri/icons/open_file_b.png";
import settingsIcon from "../src-tauri/icons/settings.png";
import settingsIconBright from "../src-tauri/icons/settings_b.png";
import sidebarCollapseIcon from "../src-tauri/icons/sb_collapse.png";
import sidebarCollapseIconBright from "../src-tauri/icons/sb_collapse_b.png";
import sidebarExpandIcon from "../src-tauri/icons/sb_expand.png";
import sidebarExpandIconBright from "../src-tauri/icons/sb_expand_b.png";
import { markdownToHTML } from "./markdown/markdown";
import "./markdown/markdown-render.css";
import {
createFolder,
createText,
deleteFolder,
deleteText,
deleteTextVersion,
discardDraft,
getDraft,
getLatestManualVersion,
getText,
listFolders,
listTexts,
listVersions,
moveFolder,
moveTextToFolder,
saveManualVersion,
searchTexts,
updateFolderName,
updateTextTitle,
upsertDraft,
type Folder,
type Text,
} from "./lib/db";
const formatDate = (timestamp: number) => {
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
};
type HistorySnapshot = {
body: string;
lastPersistedBody: string;
lastPersistedTitle: string;
hasDraft: boolean;
restoredDraft: boolean;
draftBaseVersionId: string | null;
latestManualVersionId: string | null;
};
type ConfirmState = {
title: string;
message: string;
actionLabel?: string;
onConfirm: () => Promise<void> | void;
};
type HistoryEntry = {
id: string;
created_at: number;
kind: "manual" | "draft";
body: string;
baseVersionId?: string | null;
};
type SidebarEntry =
| { kind: "folder"; item: Folder }
| { kind: "text"; item: Text };
const DEFAULT_TITLE = "Untitled Text";
const DEFAULT_FOLDER_NAME = "New Folder";
export default function App() {
const [texts, setTexts] = useState<Text[]>([]);
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [loadingTexts, setLoadingTexts] = useState(true);
const [folders, setFolders] = useState<Folder[]>([]);
const [loadingFolders, setLoadingFolders] = useState(true);
const [title, setTitle] = useState("");
const [lastPersistedTitle, setLastPersistedTitle] = useState("");
const [body, setBody] = useState("");
const [lastPersistedBody, setLastPersistedBody] = useState("");
const [hasDraft, setHasDraft] = useState(false);
const [restoredDraft, setRestoredDraft] = useState(false);
const [latestManualVersionId, setLatestManualVersionId] = useState<string | null>(null);
const [draftBaseVersionId, setDraftBaseVersionId] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyItems, setHistoryItems] = useState<HistoryEntry[]>([]);
const [viewingVersion, setViewingVersion] = useState<HistoryEntry | null>(null);
const [selectedHistoryId, setSelectedHistoryId] = useState<string | null>(null);
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState(false);
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingTextTitle, setEditingTextTitle] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => {
const stored = localStorage.getItem("textdb.expandedFolders");
if (!stored) return new Set();
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((value) => typeof value === "string"));
}
} catch {
return new Set();
}
return new Set();
});
const [theme, setTheme] = useState<"default" | "light">(() => {
const storedTheme = localStorage.getItem("textdb.theme");
return storedTheme === "light" ? "light" : "default";
});
const [textSize, setTextSize] = useState(() => {
const storedSize = Number(localStorage.getItem("textdb.textSize"));
if (!Number.isNaN(storedSize) && storedSize >= 12 && storedSize <= 18) {
return storedSize;
}
return 16;
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
return localStorage.getItem("textdb.lineNumbers") === "true";
});
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
});
const [editorReady, setEditorReady] = useState(false);
const bodyRef = useRef(body);
const editorViewRef = useRef<EditorView | null>(null);
const editorValueRef = useRef("");
const lineNumbersCompartmentRef = useRef(new Compartment());
const editableCompartmentRef = useRef(new Compartment());
const tabKeymapCompartmentRef = useRef(new Compartment());
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
const recentOpenRef = useRef(new Map<string, number>());
const ignoreTextBlurRef = useRef(false);
const ignoreFolderBlurRef = useRef(false);
bodyRef.current = body;
useEffect(() => {
document.body.dataset.theme = theme;
localStorage.setItem("textdb.theme", theme);
}, [theme]);
useEffect(() => {
document.documentElement.style.setProperty("--base-font-size", `${textSize}px`);
localStorage.setItem("textdb.textSize", String(textSize));
}, [textSize]);
useEffect(() => {
localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed));
}, [sidebarCollapsed]);
useEffect(() => {
localStorage.setItem("textdb.lineNumbers", String(showLineNumbers));
}, [showLineNumbers]);
useEffect(() => {
localStorage.setItem(
"textdb.expandedFolders",
JSON.stringify(Array.from(expandedFolders))
);
}, [expandedFolders]);
useEffect(() => {
if (selectedTextId) {
localStorage.setItem("textdb.selectedTextId", selectedTextId);
}
}, [selectedTextId]);
const editorHostRef = useCallback((node: HTMLDivElement | null) => {
if (!node) {
if (editorViewRef.current) {
editorViewRef.current.destroy();
editorViewRef.current = null;
setEditorReady(false);
}
return;
}
if (editorViewRef.current) return;
const state = EditorState.create({
doc: bodyRef.current,
extensions: [
EditorView.lineWrapping,
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbersCompartmentRef.current.of([]),
editableCompartmentRef.current.of(EditorView.editable.of(true)),
tabKeymapCompartmentRef.current.of([]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const value = update.state.doc.toString();
editorValueRef.current = value;
setBody(value);
})
]
});
const view = new EditorView({
state,
parent: node
});
editorViewRef.current = view;
editorValueRef.current = bodyRef.current;
setEditorReady(true);
}, []);
const isViewingHistory = viewingVersion !== null;
const isDirty = !isViewingHistory && body !== lastPersistedBody;
const hasText = body.trim().length > 0;
const showLineNumbersActive = showLineNumbers && !markdownPreview;
const hasSearch = search.trim().length > 0;
const markdownHtml = useMemo(
() => (markdownPreview ? markdownToHTML(body) : ""),
[body, markdownPreview]
);
const folderById = useMemo(() => {
const map = new Map<string, Folder>();
for (const folder of folders) {
map.set(folder.id, folder);
}
return map;
}, [folders]);
const visibleFolderIds = useMemo(() => {
if (!hasSearch) return null;
const visible = new Set<string>();
for (const text of texts) {
let current = text.folder_id ?? null;
while (current) {
if (visible.has(current)) break;
visible.add(current);
current = folderById.get(current)?.parent_id ?? null;
}
}
return visible;
}, [folderById, hasSearch, texts]);
const entriesByParent = useMemo(() => {
const map = new Map<string | null, SidebarEntry[]>();
const addEntry = (parentId: string | null, entry: SidebarEntry) => {
const list = map.get(parentId);
if (list) {
list.push(entry);
} else {
map.set(parentId, [entry]);
}
};
for (const folder of folders) {
if (hasSearch && visibleFolderIds && !visibleFolderIds.has(folder.id)) {
continue;
}
addEntry(folder.parent_id ?? null, { kind: "folder", item: folder });
}
for (const text of texts) {
addEntry(text.folder_id ?? null, { kind: "text", item: text });
}
for (const [key, list] of map.entries()) {
list.sort((a, b) => b.item.updated_at - a.item.updated_at);
map.set(key, list);
}
return map;
}, [folders, hasSearch, texts, visibleFolderIds]);
const handleMarkdownPreviewClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null;
const copyButton = target?.closest?.(".md-codeblock__copy") as HTMLElement | null;
if (copyButton) {
event.preventDefault();
const encoded = copyButton.getAttribute("data-copy-code") ?? "";
const text = decodeURIComponent(encoded);
if (!text) return;
writeClipboardText(text).catch(() => {
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text);
}
});
return;
}
const link = target?.closest?.("a");
if (!link) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#") || href.startsWith("/")) return;
event.preventDefault();
openExternal(href);
},
[]
);
const handlePrintMarkdown = useCallback(() => {
if (!markdownPreview) return;
document.body.classList.add("print-markdown");
const cleanup = () => {
document.body.classList.remove("print-markdown");
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
requestAnimationFrame(() => {
window.print();
});
}, [markdownPreview]);
const statusKey = useMemo(() => {
if (isViewingHistory) return "history";
if (isDirty) return "unsaved";
if (hasDraft) return "draft";
return "saved";
}, [hasDraft, isDirty, isViewingHistory]);
const canSave = !isViewingHistory && (isDirty || hasDraft);
const statusLabel = useMemo(() => {
switch (statusKey) {
case "history":
return "Viewing history";
case "unsaved":
return "Unsaved";
case "draft":
return "Draft autosaved";
default:
return "Saved";
}
}, [statusKey]);
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
const folderIconSrc = theme === "light" ? folderIconBright : folderIcon;
const newFolderIconSrc = theme === "light" ? newFolderIconBright : newFolderIcon;
const newTextIconSrc = theme === "light" ? newTextIconBright : newTextIcon;
const openFileIconSrc = theme === "light" ? openFileIconBright : openFileIcon;
const settingsIconSrc = theme === "light" ? settingsIconBright : settingsIcon;
const sidebarCollapseIconSrc =
theme === "light" ? sidebarCollapseIconBright : sidebarCollapseIcon;
const sidebarExpandIconSrc =
theme === "light" ? sidebarExpandIconBright : sidebarExpandIcon;
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
const extensions = showLineNumbersActive
? [lineNumbers(), highlightActiveLineGutter()]
: [];
view.dispatch({
effects: lineNumbersCompartmentRef.current.reconfigure(extensions)
});
}, [editorReady, showLineNumbersActive]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: editableCompartmentRef.current.reconfigure(
EditorView.editable.of(!isViewingHistory && !markdownPreview)
)
});
}, [editorReady, isViewingHistory, markdownPreview]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
const keys = Prec.high(
keymap.of([
{
key: "Tab",
run: () => {
if (
!selectedTextId ||
settingsOpen ||
confirmState ||
editingFolderId ||
editingTextId
) {
return false;
}
setMarkdownPreview((value) => !value);
return true;
}
}
])
);
view.dispatch({
effects: tabKeymapCompartmentRef.current.reconfigure(keys)
});
}, [
confirmState,
editorReady,
editingFolderId,
editingTextId,
selectedTextId,
settingsOpen
]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
if (body === editorValueRef.current) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: body },
annotations: Transaction.addToHistory.of(false)
});
editorValueRef.current = body;
}, [body]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.requestMeasure();
}, [markdownPreview, textSize, sidebarCollapsed, historyOpen]);
const refreshTexts = useCallback(async () => {
setLoadingTexts(true);
try {
const trimmed = search.trim();
const rows = trimmed ? await searchTexts(trimmed) : await listTexts();
setTexts(rows);
} finally {
setLoadingTexts(false);
}
}, [search]);
const refreshFolders = useCallback(async () => {
setLoadingFolders(true);
try {
const rows = await listFolders();
setFolders(rows);
} finally {
setLoadingFolders(false);
}
}, []);
const isFolderExpanded = useCallback(
(folderId: string) => {
if (hasSearch) {
return visibleFolderIds?.has(folderId) ?? false;
}
return expandedFolders.has(folderId);
},
[expandedFolders, hasSearch, visibleFolderIds]
);
const toggleFolderExpanded = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
const refreshVersions = useCallback(async () => {
if (!selectedTextId || !historyOpen) return;
const [manualRows, draft] = await Promise.all([
listVersions(selectedTextId),
getDraft(selectedTextId)
]);
const manualItems: HistoryEntry[] = manualRows.map((row) => ({
id: row.id,
created_at: row.created_at,
kind: "manual",
body: row.body
}));
const draftItem: HistoryEntry[] = draft
? [
{
id: `draft:${selectedTextId}`,
created_at: draft.updated_at,
kind: "draft",
body: draft.body,
baseVersionId: draft.base_version_id ?? null
}
]
: [];
const combined = [...draftItem, ...manualItems].sort(
(a, b) => b.created_at - a.created_at
);
setHistoryItems(combined);
}, [historyOpen, selectedTextId]);
useEffect(() => {
refreshTexts().catch((error) => {
console.error("Failed to load texts", error);
});
}, [refreshTexts]);
useEffect(() => {
refreshFolders().catch((error) => {
console.error("Failed to load folders", error);
});
}, [refreshFolders]);
useEffect(() => {
if (selectedTextId || texts.length === 0) return;
const storedId = localStorage.getItem("textdb.selectedTextId");
const fallback = texts[0].id;
const resolved = storedId && texts.some((text) => text.id === storedId)
? storedId
: fallback;
setSelectedTextId(resolved);
}, [selectedTextId, texts]);
useEffect(() => {
if (!historyOpen) {
setHistoryItems([]);
return;
}
refreshVersions().catch((error) => {
console.error("Failed to load versions", error);
});
}, [historyOpen, refreshVersions]);
useEffect(() => {
if (!selectedTextId) {
setTitle("");
setLastPersistedTitle("");
setBody("");
setLastPersistedBody("");
setHasDraft(false);
setRestoredDraft(false);
setLatestManualVersionId(null);
setDraftBaseVersionId(null);
setViewingVersion(null);
setSelectedHistoryId(null);
setMarkdownPreview(false);
historySnapshotRef.current = null;
return;
}
let cancelled = false;
const loadText = async () => {
const [text, manualVersion, draft] = await Promise.all([
getText(selectedTextId),
getLatestManualVersion(selectedTextId),
getDraft(selectedTextId)
]);
if (cancelled) return;
if (!text) {
setSelectedTextId(null);
localStorage.removeItem("textdb.selectedTextId");
return;
}
const resolvedTitle = text.title || DEFAULT_TITLE;
const resolvedBody = draft?.body ?? manualVersion?.body ?? "";
const baseVersionId = draft?.base_version_id ?? manualVersion?.id ?? null;
setTitle(resolvedTitle);
setLastPersistedTitle(resolvedTitle);
setBody(resolvedBody);
setLastPersistedBody(resolvedBody);
setHasDraft(Boolean(draft));
setRestoredDraft(Boolean(draft));
setLatestManualVersionId(manualVersion?.id ?? null);
setDraftBaseVersionId(baseVersionId);
setViewingVersion(null);
setSelectedHistoryId(
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
);
setMarkdownPreview(false);
historySnapshotRef.current = null;
};
loadText().catch((error) => {
console.error("Failed to load text", error);
});
return () => {
cancelled = true;
};
}, [selectedTextId]);
useEffect(() => {
if (!selectedTextId || !isDirty || isViewingHistory) return;
const currentBody = body;
const handle = window.setTimeout(() => {
upsertDraft(selectedTextId, currentBody, draftBaseVersionId)
.then(() => {
if (bodyRef.current !== currentBody) return;
setHasDraft(true);
setLastPersistedBody(currentBody);
setSelectedHistoryId(`draft:${selectedTextId}`);
if (historyOpen) {
refreshVersions().catch((error) => {
console.error("Failed to refresh history", error);
});
}
})
.catch((error) => {
console.error("Failed to autosave draft", error);
});
}, 600);
return () => window.clearTimeout(handle);
}, [
body,
draftBaseVersionId,
historyOpen,
isDirty,
isViewingHistory,
refreshVersions,
selectedTextId
]);
const handleNewText = useCallback(async () => {
const { textId } = await createText(DEFAULT_TITLE, "", null);
await refreshTexts();
setSelectedTextId(textId);
}, [refreshTexts]);
const handleNewFolder = useCallback(async () => {
const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null);
await refreshFolders();
setEditingTextId(null);
setEditingTextTitle("");
setEditingFolderId(folderId);
setEditingFolderName(DEFAULT_FOLDER_NAME);
}, [refreshFolders]);
const clearFolderEditing = useCallback(() => {
setEditingFolderId(null);
setEditingFolderName("");
}, []);
const clearTextEditing = useCallback(() => {
setEditingTextId(null);
setEditingTextTitle("");
}, []);
const startEditingFolder = useCallback((folder: Folder) => {
setEditingFolderId(folder.id);
setEditingFolderName(folder.name);
setEditingTextId(null);
setEditingTextTitle("");
}, []);
const startEditingText = useCallback((text: Text) => {
setEditingTextId(text.id);
setEditingTextTitle(text.title);
setEditingFolderId(null);
setEditingFolderName("");
}, []);
const commitFolderEdit = useCallback(async () => {
if (!editingFolderId) return;
const folderId = editingFolderId;
const nextName = editingFolderName.trim() || DEFAULT_FOLDER_NAME;
const currentName = folderById.get(folderId)?.name ?? "";
clearFolderEditing();
if (nextName === currentName) return;
await updateFolderName(folderId, nextName);
await refreshFolders();
}, [
clearFolderEditing,
editingFolderId,
editingFolderName,
folderById,
refreshFolders
]);
const commitTextEdit = useCallback(async () => {
if (!editingTextId) return;
const textId = editingTextId;
const nextTitle = editingTextTitle.trim() || DEFAULT_TITLE;
const currentTitle = texts.find((text) => text.id === textId)?.title ?? "";
clearTextEditing();
if (nextTitle === currentTitle) return;
if (selectedTextId === textId) {
setTitle(nextTitle);
setLastPersistedTitle(nextTitle);
}
await updateTextTitle(textId, nextTitle);
await refreshTexts();
}, [
clearTextEditing,
editingTextId,
editingTextTitle,
refreshTexts,
selectedTextId,
texts
]);
const buildFolderPath = useCallback(
(folderId: string) => {
const names: string[] = [];
let current: string | null = folderId;
const seen = new Set<string>();
while (current) {
if (seen.has(current)) break;
seen.add(current);
const folder = folderById.get(current);
if (!folder) break;
names.unshift(folder.name);
current = folder.parent_id ?? null;
}
return names.join(" / ");
},
[folderById]
);
const folderPathList = useMemo(() => {
return folders
.map((folder) => ({
id: folder.id,
label: buildFolderPath(folder.id)
}))
.sort((a, b) => a.label.localeCompare(b.label));
}, [buildFolderPath, folders]);
const isDescendantFolder = useCallback(
(folderId: string, potentialAncestorId: string) => {
let current: string | null = folderById.get(folderId)?.parent_id ?? null;
while (current) {
if (current === potentialAncestorId) return true;
current = folderById.get(current)?.parent_id ?? null;
}
return false;
},
[folderById]
);
const handleMoveTextToFolder = useCallback(
async (textId: string, folderId: string | null) => {
await moveTextToFolder(textId, folderId);
await refreshTexts();
if (folderId) {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderId);
return next;
});
}
},
[refreshTexts]
);
const handleMoveFolderToFolder = useCallback(
async (folderId: string, parentId: string | null) => {
await moveFolder(folderId, parentId);
await refreshFolders();
if (parentId) {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(parentId);
return next;
});
}
},
[refreshFolders]
);
const handleDeleteText = useCallback(
async (promptId: string) => {
await deleteText(promptId);
await refreshTexts();
if (selectedTextId === promptId) {
setSelectedTextId(null);
localStorage.removeItem("textdb.selectedTextId");
}
},
[refreshTexts, selectedTextId]
);
const handleDeleteFolder = useCallback(
async (folderId: string) => {
await deleteFolder(folderId);
await Promise.all([refreshFolders(), refreshTexts()]);
setExpandedFolders((prev) => {
const next = new Set(prev);
next.delete(folderId);
return next;
});
if (editingFolderId === folderId) {
clearFolderEditing();
}
},
[clearFolderEditing, editingFolderId, refreshFolders, refreshTexts]
);
const handleTextContextMenu = useCallback(
async (event: React.MouseEvent, text: Text) => {
event.preventDefault();
const textId = text.id;
const items = [
{
text: "Top level",
action: () => {
handleMoveTextToFolder(textId, null).catch((error) => {
console.error("Failed to move text", error);
});
}
},
...folderPathList.map((folder) => ({
text: folder.label,
action: () => {
handleMoveTextToFolder(textId, folder.id).catch((error) => {
console.error("Failed to move text", error);
});
}
}))
];
const menu = await Menu.new({
items: [
{
text: "Rename",
action: () => startEditingText(text)
},
{
text: "Delete",
action: () => {
setConfirmState({
title: "Delete text",
message: `Delete \"${text.title}\"? This removes all versions and drafts.`,
actionLabel: "Delete text",
onConfirm: () => handleDeleteText(text.id)
});
}
},
{
text: "Move to folder",
items
}
]
});
await menu.popup(undefined, getCurrentWindow());
},
[folderPathList, handleDeleteText, handleMoveTextToFolder, startEditingText]
);
const handleFolderContextMenu = useCallback(
async (event: React.MouseEvent, folder: Folder) => {
event.preventDefault();
const moveTargets = [
{
text: "Top level",
action: () => {
handleMoveFolderToFolder(folder.id, null).catch((error) => {
console.error("Failed to move folder", error);
});
}
},
...folderPathList
.filter(
(candidate) =>
candidate.id !== folder.id &&
!isDescendantFolder(candidate.id, folder.id)
)
.map((candidate) => ({
text: candidate.label,
action: () => {
handleMoveFolderToFolder(folder.id, candidate.id).catch((error) => {
console.error("Failed to move folder", error);
});
}
}))
];
const menu = await Menu.new({
items: [
{
text: "Rename",
action: () => startEditingFolder(folder)
},
{
text: "Delete",
action: () => {
setConfirmState({
title: "Delete folder",
message:
"Delete this folder? Its subfolders and texts will move one level up.",
actionLabel: "Delete folder",
onConfirm: () => handleDeleteFolder(folder.id)
});
}
},
{
text: "Move to folder",
items: moveTargets
}
]
});
await menu.popup(undefined, getCurrentWindow());
},
[folderPathList, handleDeleteFolder, handleMoveFolderToFolder, isDescendantFolder, startEditingFolder]
);
const createTextFromFile = useCallback(
async (filePath: string) => {
try {
const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE;
const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE;
const contents = await readTextFile(filePath);
const { textId } = await createText(title, contents, null);
await refreshTexts();
setSelectedTextId(textId);
} catch (error) {
console.error("Failed to open text file", error);
}
},
[refreshTexts]
);
const handleFilePaths = useCallback(
async (paths: string[]) => {
const now = Date.now();
const txtPaths = paths.filter((path) => {
const lower = path.toLowerCase();
return lower.endsWith(".txt") || lower.endsWith(".md");
});
const recent = recentOpenRef.current;
for (const path of txtPaths) {
const key = path.toLowerCase();
const last = recent.get(key);
if (last && now - last < 1000) continue;
recent.set(key, now);
await createTextFromFile(path);
}
for (const [key, timestamp] of recent.entries()) {
if (now - timestamp > 2000) recent.delete(key);
}
},
[createTextFromFile]
);
useEffect(() => {
let unlisten: (() => void) | null = null;
getCurrentWindow()
.onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
await handleFilePaths(event.payload.paths ?? []);
})
.then((cleanup) => {
unlisten = cleanup;
})
.catch((error) => {
console.error("Failed to register drag/drop handler", error);
});
return () => {
if (unlisten) unlisten();
};
}, [handleFilePaths]);
useEffect(() => {
let unlisten: (() => void) | null = null;
listen<string[]>("file-opened", async (event) => {
await handleFilePaths(event.payload ?? []);
})
.then((cleanup) => {
unlisten = cleanup;
})
.catch((error) => {
console.error("Failed to register file-open listener", error);
});
invoke<string[]>("take_pending_opens")
.then((paths) => handleFilePaths(paths ?? []))
.catch((error) => {
console.error("Failed to load pending file opens", error);
});
return () => {
if (unlisten) unlisten();
};
}, [handleFilePaths]);
const handleOpenText = useCallback(async () => {
const baseDir = await appDataDir();
const path = await open({
multiple: false,
directory: false,
filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }],
defaultPath: baseDir
});
if (!path || Array.isArray(path)) return;
await createTextFromFile(path);
}, [createTextFromFile]);
const handleSaveVersion = useCallback(async () => {
if (!selectedTextId || !canSave) return;
const normalizedTitle = title.trim() || DEFAULT_TITLE;
if (normalizedTitle !== title) {
setTitle(normalizedTitle);
}
const result = await saveManualVersion(selectedTextId, normalizedTitle, body);
setLastPersistedBody(body);
setLastPersistedTitle(normalizedTitle);
setHasDraft(false);
setRestoredDraft(false);
setLatestManualVersionId(result.versionId);
setDraftBaseVersionId(result.versionId);
setSelectedHistoryId(result.versionId);
await refreshTexts();
await refreshVersions();
}, [body, canSave, refreshTexts, refreshVersions, selectedTextId, title]);
const applyTitleUpdate = useCallback((promptId: string, nextTitle: string) => {
const now = Date.now();
setTexts((prev) =>
prev.map((text) =>
text.id === promptId ? { ...text, title: nextTitle, updated_at: now } : text
)
);
}, []);
const handleTitleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextTitle = event.target.value;
setTitle(nextTitle);
if (!selectedTextId || isViewingHistory) return;
applyTitleUpdate(selectedTextId, nextTitle);
setLastPersistedTitle(nextTitle);
updateTextTitle(selectedTextId, nextTitle).catch((error) => {
console.error("Failed to update title", error);
});
},
[applyTitleUpdate, isViewingHistory, selectedTextId]
);
const handleDiscardDraft = useCallback(async () => {
if (!selectedTextId || isViewingHistory) return;
await discardDraft(selectedTextId);
const manualVersion = await getLatestManualVersion(selectedTextId);
const resolvedBody = manualVersion?.body ?? "";
setBody(resolvedBody);
setLastPersistedBody(resolvedBody);
setHasDraft(false);
setRestoredDraft(false);
setLatestManualVersionId(manualVersion?.id ?? null);
setDraftBaseVersionId(manualVersion?.id ?? null);
setSelectedHistoryId(manualVersion?.id ?? null);
await refreshVersions();
}, [isViewingHistory, refreshVersions, selectedTextId]);
const handleExportText = useCallback(async () => {
if (!hasText) return;
const filename = `${title.trim() || DEFAULT_TITLE}.txt`;
const path = await save({
defaultPath: filename,
filters: [{ name: "Text", extensions: ["txt"] }]
});
if (!path) return;
const finalPath = path.endsWith(".txt") ? path : `${path}.txt`;
await writeTextFile(finalPath, body);
}, [body, hasText, title]);
const handleExitHistory = useCallback(() => {
const snapshot = historySnapshotRef.current;
if (snapshot) {
setBody(snapshot.body);
setLastPersistedBody(snapshot.lastPersistedBody);
setLastPersistedTitle(snapshot.lastPersistedTitle);
setHasDraft(snapshot.hasDraft);
setRestoredDraft(snapshot.restoredDraft);
setDraftBaseVersionId(snapshot.draftBaseVersionId);
setLatestManualVersionId(snapshot.latestManualVersionId);
}
setViewingVersion(null);
setSelectedHistoryId(null);
historySnapshotRef.current = null;
}, []);
const applyVersionAsCurrent = useCallback(
(version: HistoryEntry) => {
const snapshot = historySnapshotRef.current;
if (snapshot) {
setLastPersistedBody(snapshot.lastPersistedBody);
setLastPersistedTitle(snapshot.lastPersistedTitle);
setHasDraft(snapshot.hasDraft);
setRestoredDraft(snapshot.restoredDraft);
setLatestManualVersionId(snapshot.latestManualVersionId);
}
setDraftBaseVersionId(
version.kind === "manual" ? version.id : version.baseVersionId ?? null
);
setBody(version.body);
setLastPersistedBody(version.body);
setViewingVersion(null);
setSelectedHistoryId(version.id);
historySnapshotRef.current = null;
},
[]
);
const handleDeleteVersion = useCallback(
async (version: HistoryEntry) => {
if (!selectedTextId) return;
const currentItems = historyItems;
const index = currentItems.findIndex((item) => item.id === version.id);
const olderCandidate =
index >= 0 ? currentItems[index + 1] ?? null : null;
const fallbackCandidate =
index > 0 ? currentItems[index - 1] ?? null : null;
const shouldAutoSelect =
historyOpen && (!viewingVersion || viewingVersion.id === version.id);
if (version.kind === "draft") {
await discardDraft(selectedTextId);
setHasDraft(false);
setRestoredDraft(false);
setDraftBaseVersionId(latestManualVersionId ?? null);
if (historySnapshotRef.current) {
historySnapshotRef.current.hasDraft = false;
historySnapshotRef.current.restoredDraft = false;
historySnapshotRef.current.draftBaseVersionId =
latestManualVersionId ?? null;
}
} else {
await deleteTextVersion(selectedTextId, version.id);
}
await refreshVersions();
if (version.kind === "manual" && latestManualVersionId === version.id) {
const manualVersion = await getLatestManualVersion(selectedTextId);
const resolvedBody = manualVersion?.body ?? "";
const nextManualId = manualVersion?.id ?? null;
setLatestManualVersionId(nextManualId);
setDraftBaseVersionId(nextManualId);
if (historySnapshotRef.current) {
historySnapshotRef.current.latestManualVersionId = nextManualId;
historySnapshotRef.current.draftBaseVersionId = nextManualId;
}
if (!hasDraft && !isViewingHistory) {
setBody(resolvedBody);
setLastPersistedBody(resolvedBody);
}
}
if (shouldAutoSelect) {
if (olderCandidate) {
applyVersionAsCurrent(olderCandidate);
} else if (fallbackCandidate) {
applyVersionAsCurrent(fallbackCandidate);
} else {
handleExitHistory();
}
}
},
[
applyVersionAsCurrent,
handleExitHistory,
hasDraft,
isViewingHistory,
historyOpen,
latestManualVersionId,
refreshVersions,
selectedTextId,
historyItems,
viewingVersion
]
);
const handleConfirm = useCallback(async () => {
if (!confirmState) return;
const action = confirmState.onConfirm;
setConfirmState(null);
try {
await action();
} catch (error) {
console.error("Delete failed", error);
}
}, [confirmState]);
const handleTitleBlur = useCallback(async () => {
if (!selectedTextId || isViewingHistory) return;
const normalizedTitle = title.trim() || DEFAULT_TITLE;
if (normalizedTitle !== title) {
setTitle(normalizedTitle);
applyTitleUpdate(selectedTextId, normalizedTitle);
}
if (normalizedTitle === lastPersistedTitle) return;
await updateTextTitle(selectedTextId, normalizedTitle);
setLastPersistedTitle(normalizedTitle);
await refreshTexts();
}, [
applyTitleUpdate,
isViewingHistory,
lastPersistedTitle,
refreshTexts,
selectedTextId,
title
]);
const handleToggleHistory = useCallback(() => {
if (historyOpen && viewingVersion) {
const snapshot = historySnapshotRef.current;
if (snapshot) {
setBody(snapshot.body);
setLastPersistedBody(snapshot.lastPersistedBody);
setLastPersistedTitle(snapshot.lastPersistedTitle);
setHasDraft(snapshot.hasDraft);
setRestoredDraft(snapshot.restoredDraft);
setDraftBaseVersionId(snapshot.draftBaseVersionId);
setLatestManualVersionId(snapshot.latestManualVersionId);
}
setViewingVersion(null);
historySnapshotRef.current = null;
}
setHistoryOpen((prev) => !prev);
}, [historyOpen, viewingVersion]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const isSave =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
if (isSave) {
event.preventDefault();
handleSaveVersion().catch((error) => {
console.error("Failed to save version", error);
});
return;
}
if (
event.key === "Tab" &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
selectedTextId &&
!settingsOpen &&
!confirmState &&
!editingFolderId &&
!editingTextId
) {
const target = event.target as HTMLElement | null;
if (target?.closest?.(".cm-editor")) return;
event.preventDefault();
setMarkdownPreview((value) => !value);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmState, editingFolderId, editingTextId, handleSaveVersion, selectedTextId, settingsOpen]);
const renderTextItem = (text: Text) => (
<div
key={text.id}
className={`prompt-item${text.id === selectedTextId ? " is-active" : ""}`}
onClick={() => {
if (editingTextId === text.id) return;
setSelectedTextId(text.id);
}}
onContextMenu={(event) => handleTextContextMenu(event, text)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedTextId(text.id);
}
}}
>
<div className="prompt-item__content">
{editingTextId === text.id ? (
<input
className="prompt-item__input"
value={editingTextTitle}
onChange={(event) => setEditingTextTitle(event.target.value)}
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitTextEdit().catch((error) => {
console.error("Failed to rename text", error);
});
} else if (event.key === "Escape") {
event.preventDefault();
ignoreTextBlurRef.current = true;
clearTextEditing();
}
}}
onBlur={() => {
if (ignoreTextBlurRef.current) {
ignoreTextBlurRef.current = false;
return;
}
commitTextEdit().catch((error) => {
console.error("Failed to rename text", error);
});
}}
autoFocus
/>
) : (
<div className="prompt-item__title">{text.title}</div>
)}
</div>
<button
className="prompt-item__delete"
onClick={(event) => {
event.stopPropagation();
setConfirmState({
title: "Delete text",
message: `Delete \"${text.title}\"? This removes all versions and drafts.`,
actionLabel: "Delete text",
onConfirm: () => handleDeleteText(text.id)
});
}}
aria-label="Delete text"
title="Delete text"
>
×
</button>
</div>
);
const renderFolder = (folder: Folder) => {
if (hasSearch && !visibleFolderIds?.has(folder.id)) return null;
const expanded = isFolderExpanded(folder.id);
const childEntries = entriesByParent.get(folder.id) ?? [];
return (
<div key={folder.id} className="folder-node">
<div
className={`folder-item${expanded ? " is-open" : ""}`}
onClick={() => {
if (editingFolderId === folder.id) return;
toggleFolderExpanded(folder.id);
}}
onContextMenu={(event) => handleFolderContextMenu(event, folder)}
>
<div className="folder-item__lead">
<button
className="folder-item__toggle"
type="button"
aria-label={expanded ? "Collapse folder" : "Expand folder"}
onClick={(event) => {
event.stopPropagation();
toggleFolderExpanded(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
<img src={folderIconSrc} alt="" className="folder-item__icon" />
</div>
{editingFolderId === folder.id ? (
<input
className="folder-item__input"
value={editingFolderName}
onChange={(event) => setEditingFolderName(event.target.value)}
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitFolderEdit().catch((error) => {
console.error("Failed to rename folder", error);
});
} else if (event.key === "Escape") {
event.preventDefault();
ignoreFolderBlurRef.current = true;
clearFolderEditing();
}
}}
onBlur={() => {
if (ignoreFolderBlurRef.current) {
ignoreFolderBlurRef.current = false;
return;
}
commitFolderEdit().catch((error) => {
console.error("Failed to rename folder", error);
});
}}
autoFocus
/>
) : (
<div className="folder-item__title">{folder.name}</div>
)}
<button
className="folder-item__delete"
onClick={(event) => {
event.stopPropagation();
setConfirmState({
title: "Delete folder",
message:
"Delete this folder? Its subfolders and texts will move one level up.",
actionLabel: "Delete folder",
onConfirm: () => handleDeleteFolder(folder.id)
});
}}
aria-label="Delete folder"
title="Delete folder"
>
×
</button>
</div>
{expanded && childEntries.length > 0 ? (
<div className="folder-children">
{childEntries.map((entry) =>
entry.kind === "folder"
? renderFolder(entry.item)
: renderTextItem(entry.item)
)}
</div>
) : null}
</div>
);
};
return (
<div className={`app app--theme-${theme}${sidebarCollapsed ? " app--sidebar-collapsed" : ""}`}>
{!sidebarCollapsed ? (
<aside className="sidebar">
<div className="sidebar__header">
<div className="sidebar__title-row">
<div className="app-title">TextDB</div>
</div>
<input
className="search"
placeholder="Search texts"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="prompt-list">
<div className="prompt-list__inner">
{loadingTexts || loadingFolders ? (
<div className="empty">Loading</div>
) : hasSearch && texts.length === 0 ? (
<div className="empty">No matching texts.</div>
) : texts.length === 0 && folders.length === 0 ? (
<div className="empty">No texts yet.</div>
) : (
<>
{(entriesByParent.get(null) ?? []).map((entry) =>
entry.kind === "folder"
? renderFolder(entry.item)
: renderTextItem(entry.item)
)}
</>
)}
</div>
</div>
<div className="sidebar__footer">
<button
className="icon-button"
onClick={handleOpenText}
aria-label="Open text"
title="Open text"
type="button"
>
<img src={openFileIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={handleNewText}
aria-label="New text"
title="New text"
type="button"
>
<img src={newTextIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={handleNewFolder}
aria-label="New folder"
title="New folder"
type="button"
>
<img src={newFolderIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
title="Settings"
type="button"
>
<img src={settingsIconSrc} alt="" className="icon-button__img" />
</button>
<button
className="icon-button"
onClick={() => setSidebarCollapsed(true)}
aria-label="Collapse sidebar"
title="Collapse sidebar"
type="button"
>
<img src={sidebarCollapseIconSrc} alt="" className="icon-button__img" />
</button>
</div>
</aside>
) : null}
<main className="workspace">
<div className="workspace__body">
{!selectedTextId ? (
<>
<div className="empty-state">
<div className="empty-state__title">Create your first text</div>
<div className="empty-state__subtitle">
Everything stays offline in a single SQLite database.
</div>
<button className="button button--primary" onClick={handleNewText}>
New Text
</button>
</div>
{sidebarCollapsed ? (
<div className="editor__footer">
<button
className="icon-button"
onClick={() => setSidebarCollapsed(false)}
aria-label="Expand sidebar"
title="Expand sidebar"
type="button"
>
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
</button>
</div>
) : null}
</>
) : (
<div
className={`workspace__content${
historyOpen ? " workspace__content--history" : ""
}`}
>
<section className="editor">
<div className="editor__header">
<div className="editor__title-row">
<input
className="title-input"
value={title}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Text title"
disabled={isViewingHistory}
/>
<button
className={`icon-button${historyOpen ? " is-active" : ""}`}
onClick={handleToggleHistory}
aria-label={historyOpen ? "Close history" : "Open history"}
title={historyOpen ? "Close history" : "Open history"}
type="button"
>
<img src={historyIconSrc} alt="" className="icon-button__img" />
</button>
</div>
</div>
<div
className={`editor__textarea-wrap${
markdownPreview ? " editor__textarea-wrap--preview" : ""
}`}
>
<div
ref={editorHostRef}
className={`editor__codemirror${
markdownPreview ? " editor__codemirror--hidden" : ""
}`}
/>
<div
className={`markdown-preview md-root${markdownPreview ? "" : " markdown-preview--hidden"}`}
dangerouslySetInnerHTML={{ __html: markdownHtml }}
onClick={handleMarkdownPreviewClick}
/>
</div>
<div className="editor__footer">
{sidebarCollapsed ? (
<button
className="icon-button"
onClick={() => setSidebarCollapsed(false)}
aria-label="Expand sidebar"
title="Expand sidebar"
type="button"
>
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
</button>
) : null}
{hasText ? (
<>
<button
className="button"
type="button"
onClick={() => setMarkdownPreview((value) => !value)}
>
{markdownPreview ? "Edit" : "Preview Markdown"}
</button>
<button className="button" onClick={handleExportText}>
Export Text
</button>
{markdownPreview ? (
<>
<button className="button" type="button" onClick={handlePrintMarkdown}>
Print
</button>
</>
) : null}
</>
) : null}
{hasDraft && !isViewingHistory ? (
<button
className="button"
onClick={() =>
setConfirmState({
title: "Discard draft",
message: "Discard this draft? This cannot be undone.",
actionLabel: "Discard draft",
onConfirm: handleDiscardDraft
})
}
>
Discard Draft
</button>
) : null}
<div className="editor__footer-status">
<div className="status-line">
<span className={`status status--${statusKey}`}></span>
{statusLabel}
</div>
</div>
<button
className="button button--primary button--save"
onClick={handleSaveVersion}
disabled={!canSave}
>
Save Version (S)
</button>
</div>
</section>
{historyOpen ? (
<aside className="history">
<div className="history__header">
<span>History</span>
<button
className="history__close"
onClick={handleToggleHistory}
aria-label="Close history"
title="Close history"
type="button"
>
×
</button>
</div>
<div className="history__list">
{historyItems.length === 0 ? (
<div className="empty">No versions yet.</div>
) : (
historyItems.map((version) => (
<div
key={version.id}
className={`history__item${
selectedHistoryId === version.id ? " is-active" : ""
}`}
onClick={() => applyVersionAsCurrent(version)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
applyVersionAsCurrent(version);
}
}}
>
<div className="history__item-content">
<div className="history__item-title">
{formatDate(version.created_at)}
</div>
<div className="history__item-meta">
{version.kind === "draft" ? "Draft" : "Manual save"}
</div>
</div>
<button
className="history__item-delete"
onClick={(event) => {
event.stopPropagation();
setConfirmState({
title:
version.kind === "draft"
? "Discard draft"
: "Delete version",
message:
version.kind === "draft"
? "Discard this draft? This cannot be undone."
: "Delete this version? This cannot be undone.",
actionLabel:
version.kind === "draft"
? "Discard draft"
: "Delete version",
onConfirm: () => handleDeleteVersion(version)
});
}}
aria-label={
version.kind === "draft"
? "Discard draft"
: "Delete version"
}
title={
version.kind === "draft"
? "Discard draft"
: "Delete version"
}
>
×
</button>
</div>
))
)}
</div>
</aside>
) : null}
</div>
)}
</div>
</main>
{settingsOpen ? (
<div className="settings-overlay">
<div
className="settings-overlay__backdrop"
onClick={() => setSettingsOpen(false)}
/>
<div className="settings-panel" role="dialog" aria-modal="true">
<div className="settings-panel__header">
<div className="settings-panel__title">Settings</div>
<button
className="icon-button icon-button--ghost"
onClick={() => setSettingsOpen(false)}
aria-label="Close settings"
title="Close settings"
type="button"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="theme-select">
Theme
</label>
<select
id="theme-select"
className="settings-panel__select"
value={theme}
onChange={(event) =>
setTheme(event.target.value as "default" | "light")
}
>
<option value="default">Default</option>
<option value="light">Bright</option>
</select>
</div>
<div className="settings-panel__section settings-panel__section--row">
<label className="settings-panel__label" htmlFor="line-numbers-toggle">
Line numbers
</label>
<input
id="line-numbers-toggle"
type="checkbox"
checked={showLineNumbers}
onChange={(event) => setShowLineNumbers(event.target.checked)}
/>
</div>
<div className="settings-panel__section">
<label className="settings-panel__label" htmlFor="text-size">
Text size
</label>
<div className="settings-panel__slider-row">
<input
id="text-size"
className="settings-panel__range"
type="range"
min={12}
max={18}
step={1}
value={textSize}
onChange={(event) => setTextSize(Number(event.target.value))}
/>
<div className="settings-panel__value">{textSize}px</div>
</div>
</div>
</div>
</div>
) : null}
{confirmState ? (
<div className="modal">
<div className="modal__overlay" onClick={() => setConfirmState(null)} />
<div className="modal__card" role="dialog" aria-modal="true">
<div className="modal__title">{confirmState.title}</div>
<div className="modal__message">{confirmState.message}</div>
<div className="modal__actions">
<button className="button" onClick={() => setConfirmState(null)}>
Cancel
</button>
<button className="button button--danger" onClick={handleConfirm}>
{confirmState.actionLabel ?? "Delete"}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}