Files
textDB/src/App.tsx

1802 lines
60 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useLayoutEffect, 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 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";
2026-01-30 23:14:06 +01:00
import {
createFolder,
2026-01-30 23:14:06 +01:00
createText,
deleteFolder,
2026-01-30 23:14:06 +01:00
deleteText,
deleteTextVersion,
discardDraft,
getDraft,
getLatestManualVersion,
getText,
listFolders,
2026-01-30 23:14:06 +01:00
listTexts,
listVersions,
moveFolder,
moveTextToFolder,
2026-01-30 23:14:06 +01:00
saveManualVersion,
searchTexts,
updateFolderName,
2026-01-30 23:14:06 +01:00
updateTextTitle,
upsertDraft,
type Folder,
2026-01-30 23:14:06 +01:00
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 };
2026-01-30 23:14:06 +01:00
const DEFAULT_TITLE = "Untitled Text";
const DEFAULT_FOLDER_NAME = "New Folder";
2026-01-30 23:14:06 +01:00
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);
2026-01-30 23:14:06 +01:00
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 [lineHeights, setLineHeights] = useState<number[]>([]);
const [measureTick, setMeasureTick] = useState(0);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
});
2026-01-30 23:14:06 +01:00
const bodyRef = useRef(body);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
2026-01-30 23:14:06 +01:00
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
const recentOpenRef = useRef(new Map<string, number>());
const ignoreTextBlurRef = useRef(false);
const ignoreFolderBlurRef = useRef(false);
2026-01-30 23:14:06 +01:00
2026-01-30 23:14:06 +01:00
useEffect(() => {
bodyRef.current = body;
}, [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]);
2026-01-30 23:14:06 +01:00
const isViewingHistory = viewingVersion !== null;
const isDirty = !isViewingHistory && body !== lastPersistedBody;
const hasText = body.trim().length > 0;
const showLineNumbersActive = showLineNumbers && !markdownPreview;
2026-01-31 18:46:08 +01:00
const hasSearch = search.trim().length > 0;
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]);
2026-01-30 23:14:06 +01:00
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]);
2026-01-30 23:14:06 +01:00
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;
2026-01-31 13:11:13 +01:00
const lines = useMemo(() => body.split("\n"), [body]);
2026-01-31 13:05:32 +01:00
const lineNumbers = useMemo(() => lines.map((_, index) => index + 1), [lines]);
const handleTextareaScroll = useCallback((event: React.UIEvent<HTMLTextAreaElement>) => {
if (!showLineNumbersActive) return;
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = event.currentTarget.scrollTop;
}
}, [showLineNumbersActive]);
useEffect(() => {
if (!showLineNumbersActive) return;
const textarea = textareaRef.current;
if (!textarea || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
setMeasureTick((tick) => tick + 1);
});
observer.observe(textarea);
return () => observer.disconnect();
}, [showLineNumbersActive]);
useEffect(() => {
if (!showLineNumbersActive) return;
let raf = 0;
const handleResize = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setMeasureTick((tick) => tick + 1);
});
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (raf) cancelAnimationFrame(raf);
};
}, [showLineNumbersActive]);
useLayoutEffect(() => {
if (!showLineNumbersActive) return;
const textarea = textareaRef.current;
const measure = measureRef.current;
if (!textarea || !measure) return;
measure.style.width = `${textarea.clientWidth}px`;
const heights = Array.from(measure.children).map((child) =>
Math.ceil((child as HTMLElement).getBoundingClientRect().height)
);
setLineHeights(heights);
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = textarea.scrollTop;
}
}, [lines, showLineNumbersActive, textSize, measureTick, sidebarCollapsed, historyOpen]);
useEffect(() => {
if (showLineNumbersActive && textareaRef.current && lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
}
}, [showLineNumbersActive, body]);
2026-01-30 23:14:06 +01:00
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]);
2026-01-31 18:45:22 +01:00
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;
});
}, []);
2026-01-30 23:14:06 +01:00
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]);
2026-01-30 23:14:06 +01:00
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);
2026-01-30 23:14:06 +01:00
}, [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);
2026-01-30 23:14:06 +01:00
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");
2026-01-30 23:14:06 +01:00
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);
2026-01-30 23:14:06 +01:00
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 () => {
2026-02-01 01:50:43 +01:00
const { textId } = await createText(DEFAULT_TITLE, "", null);
2026-01-30 23:14:06 +01:00
await refreshTexts();
setSelectedTextId(textId);
2026-02-01 01:50:43 +01:00
}, [refreshTexts]);
2026-01-30 23:14:06 +01:00
const handleNewFolder = useCallback(async () => {
2026-02-01 01:50:43 +01:00
const { folderId } = await createFolder(DEFAULT_FOLDER_NAME, null);
await refreshFolders();
setEditingTextId(null);
setEditingTextTitle("");
setEditingFolderId(folderId);
setEditingFolderName(DEFAULT_FOLDER_NAME);
2026-02-01 01:50:43 +01:00
}, [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;
2026-01-31 14:41:37 +01:00
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();
2026-01-31 14:41:37 +01:00
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,
2026-01-31 14:41:37 +01:00
filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }],
defaultPath: baseDir
});
if (!path || Array.isArray(path)) return;
await createTextFromFile(path);
}, [createTextFromFile]);
2026-01-30 23:14:06 +01:00
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();
2026-01-31 18:54:10 +01:00
setTexts((prev) =>
prev.map((text) =>
text.id === promptId ? { ...text, title: nextTitle, updated_at: now } : text
)
);
2026-01-30 23:14:06 +01:00
}, []);
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) => {
const isSave =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
2026-01-31 16:58:30 +01:00
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
2026-01-31 16:58:30 +01:00
) {
event.preventDefault();
setMarkdownPreview((value) => !value);
}
2026-01-30 23:14:06 +01:00
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
2026-01-31 19:12:05 +01:00
}, [confirmState, editingFolderId, editingTextId, handleSaveVersion, selectedTextId, settingsOpen]);
2026-01-30 23:14:06 +01:00
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)}
>
2026-02-01 02:00:15 +01:00
<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>
)}
2026-02-01 01:09:48 +01:00
<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>
);
};
2026-01-30 23:14:06 +01:00
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}
2026-01-30 23:14:06 +01:00
<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" : ""
}`}
>
2026-01-30 23:14:06 +01:00
<section className="editor">
<div className="editor__header">
2026-01-31 13:37:41 +01:00
<div className="editor__title-row">
<input
className="title-input"
value={title}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Text title"
disabled={isViewingHistory}
/>
2026-01-30 23:14:06 +01:00
<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" />
2026-01-30 23:14:06 +01:00
</button>
</div>
</div>
<div
className={`editor__textarea-wrap${
markdownPreview ? " editor__textarea-wrap--preview" : ""
}`}
>
{showLineNumbersActive ? (
<div className="line-measure" ref={measureRef} aria-hidden="true">
{lines.map((line, index) => (
<div key={index} className="line-measure__line">
{line.length > 0 ? line : " "}
</div>
))}
</div>
) : null}
{showLineNumbersActive ? (
<div className="line-numbers" ref={lineNumbersRef}>
{lineNumbers.map((line, index) => (
<div
key={line}
className="line-numbers__line"
style={{ height: lineHeights[index] ? `${lineHeights[index]}px` : undefined }}
>
{line}
</div>
))}
</div>
) : null}
{markdownPreview ? (
<div
className="markdown-preview md-root"
dangerouslySetInnerHTML={{ __html: markdownToHTML(body) }}
onClick={handleMarkdownPreviewClick}
/>
) : (
<textarea
ref={textareaRef}
className="editor__textarea"
value={body}
onChange={(event) => setBody(event.target.value)}
onScroll={handleTextareaScroll}
placeholder="Write your text here…"
readOnly={isViewingHistory}
/>
)}
</div>
2026-01-30 23:14:06 +01:00
<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}
2026-01-30 23:14:06 +01:00
{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}
</>
2026-01-30 23:14:06 +01:00
) : 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}
2026-01-31 13:37:41 +01:00
<div className="editor__footer-status">
<div className="status-line">
<span className={`status status--${statusKey}`}></span>
{statusLabel}
</div>
</div>
2026-01-30 23:14:06 +01:00
<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>
2026-01-30 23:14:06 +01:00
</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}
2026-01-30 23:14:06 +01:00
{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>
);
}