initial commit

This commit is contained in:
2026-01-30 23:14:06 +01:00
commit 22cb32d56c
20 changed files with 7349 additions and 0 deletions

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# TextDB
TextDB is a **non-destructive**, completely offline, local-first text editor. Its core promise is simple: your edits never overwrite your history. Every manual save is immutable and append-only, and autosaves are kept as a separate draft layer. No accounts, no telemetry, no remote fonts, and no network calls.
## Features
- Non-destructive editing: manual saves are immutable; drafts never overwrite manual versions.
- Sidebar list of texts with live search (titles + version bodies).
- Editor with title, content area, and clear save status.
- Cmd/Ctrl+S creates a new immutable manual version.
- Autosave draft (debounced) that can be safely discarded.
- History panel listing versions by timestamp (drafts included when present).
- Export the current text to a `.txt` file.
## Use cases
- **Non-destructive notepad**: jot ideas without fear of losing earlier thoughts.
- **Safe editor for authors**: keep every revision and compare or roll back at any time.
- **Research notes**: maintain evolving notes with an audit trail of changes.
- **Sensitive drafts**: keep local-only writing with immutable history and zero cloud sync.
- **Prompt or snippet library**: store and iterate on reusable text safely.
## Tech stack
- Tauri v2
- React + Vite + TypeScript
- SQLite via `@tauri-apps/plugin-sql`
## Install & run
1) Install dependencies:
```
npm install
```
2) Start the app:
```
npm run tauri dev
```
## Autosave + versioning behavior
- Typing triggers a debounced autosave (~600ms) that writes to the draft record.
- Manual saves (Cmd/Ctrl+S or the button) append a new version and clear any draft.
- Autosave only overwrites the single draft row; manual versions are never overwritten.
- History shows all manual versions plus the current draft (if any).
## Local storage
- SQLite is loaded via `Database.load("sqlite:text.db")`.
- The database file is stored in the Tauri app data directory.
- The app is fully offline by design: no telemetry, no external fonts, no CDNs.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TextDB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "textdb",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-sql": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

5456
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "textdb"
version = "0.1.0"
description = "TextDB"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = { version = "2" }
tauri-plugin-fs = { version = "2" }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Default capabilities for TextDB",
"windows": ["main"],
"permissions": [
"core:default",
"sql:default",
"sql:allow-execute",
"dialog:default",
"fs:default",
"fs:allow-write-file",
"fs:allow-write-text-file",
"fs:allow-home-write-recursive"
]
}

BIN
src-tauri/icons/history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

10
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,10 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_sql::Builder::default().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

35
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,35 @@
{
"$schema": "./gen/schemas/desktop-schema.json",
"productName": "TextDB",
"version": "0.1.0",
"identifier": "com.textdb.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "TextDB",
"width": 1100,
"height": 720,
"minWidth": 980,
"minHeight": 640,
"resizable": true
}
],
"security": {
"csp": null,
"capabilities": ["main"]
}
},
"plugins": {
"sql": {
"preload": ["sqlite:text.db"]
}
}
}

782
src/App.tsx Normal file
View File

@@ -0,0 +1,782 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import historyIcon from "./assets/history.png";
import {
createText,
deleteText,
deleteTextVersion,
discardDraft,
getDraft,
getLatestManualVersion,
getText,
listTexts,
listVersions,
searchTexts,
saveManualVersion,
updateTextTitle,
upsertDraft,
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;
};
const DEFAULT_TITLE = "Untitled Text";
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 [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 bodyRef = useRef(body);
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
useEffect(() => {
bodyRef.current = body;
}, [body]);
const isViewingHistory = viewingVersion !== null;
const isDirty = !isViewingHistory && body !== lastPersistedBody;
const hasText = body.trim().length > 0;
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 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 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(() => {
if (!selectedTextId && texts.length > 0) {
setSelectedTextId(texts[0].id);
}
}, [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);
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);
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
);
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, "");
await refreshTexts();
setSelectedTextId(textId);
}, [refreshTexts]);
const handleDeleteText = useCallback(
async (promptId: string) => {
await deleteText(promptId);
await refreshTexts();
if (selectedTextId === promptId) {
setSelectedTextId(null);
}
},
[refreshTexts, selectedTextId]
);
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) => {
let updated: Text | null = null;
const remaining: Text[] = [];
for (const text of prev) {
if (text.id === promptId) {
updated = { ...text, title: nextTitle, updated_at: now };
} else {
remaining.push(text);
}
}
if (!updated) return prev;
return [updated, ...remaining];
});
}, []);
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";
if (!isSave) return;
event.preventDefault();
handleSaveVersion().catch((error) => {
console.error("Failed to save version", error);
});
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSaveVersion]);
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar__header">
<div className="app-title">TextDB</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 ? (
<div className="empty">Loading texts</div>
) : texts.length === 0 ? (
<div className="empty">No texts yet.</div>
) : (
texts.map((text) => (
<div
key={text.id}
className={`prompt-item${
text.id === selectedTextId ? " is-active" : ""
}`}
onClick={() => setSelectedTextId(text.id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedTextId(text.id);
}
}}
>
<div className="prompt-item__content">
<div className="prompt-item__title">{text.title}</div>
<div className="prompt-item__meta">
Updated {formatDate(text.updated_at)}
</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>
))
)}
</div>
</div>
<div className="sidebar__footer">
<button className="button button--primary" onClick={handleNewText}>
New Text
</button>
</div>
</aside>
<main className="workspace">
{!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>
) : (
<div
className={`workspace__content${
historyOpen ? " workspace__content--history" : ""
}`}
>
<section className="editor">
<div className="editor__header">
<input
className="title-input"
value={title}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Text title"
disabled={isViewingHistory}
/>
<div className="editor__status-row">
<div className="status-line">
<span className={`status status--${statusKey}`}></span>
{statusLabel}
</div>
<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={historyIcon} alt="" className="icon-button__img" />
</button>
</div>
</div>
<textarea
className="editor__textarea"
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="Write your text here…"
readOnly={isViewingHistory}
/>
<div className="editor__footer">
{hasText ? (
<button className="button" onClick={handleExportText}>
Export Text
</button>
) : 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}
<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>
)}
</main>
{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>
);
}

BIN
src/assets/history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

613
src/index.css Normal file
View File

@@ -0,0 +1,613 @@
:root {
color-scheme: dark;
font-family: "SF Pro Text", "Segoe UI", "Helvetica Neue", "Noto Sans", sans-serif;
font-size: 16px;
line-height: 1.5;
font-weight: 400;
--bg: #141414;
--bg-elevated: #1b1b1b;
--panel: #1b1b1b;
--ink: #e5e5e5;
--muted: #a0a0a0;
--accent: #3a3a3a;
--accent-strong: #5a5a5a;
--accent-warm: #5a5a5a;
--border: #2a2a2a;
--shadow: 0 14px 30px rgba(0, 0, 0, 0.45);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
overflow: hidden;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
}
#root {
height: 100vh;
min-height: 100vh;
}
.app {
display: grid;
grid-template-columns: 300px 1fr;
height: 100vh;
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 0;
background: var(--panel);
border-right: 1px solid var(--border);
height: 100vh;
overflow: hidden;
}
.sidebar__header {
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 24px;
padding-right: 24px;
}
.sidebar__footer {
margin-top: auto;
padding-left: 24px;
padding-right: 24px;
}
.sidebar__footer .button {
width: 100%;
}
.app-title {
font-size: 1.4rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink);
}
.search {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--ink);
font-size: 0.95rem;
}
.button {
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 16px;
background: var(--bg-elevated);
color: var(--ink);
font-size: 0.95rem;
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.button:hover {
box-shadow: var(--shadow);
border-color: rgba(255, 255, 255, 0.12);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
.button--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--ink);
}
.button--primary:hover {
border-color: var(--accent-strong);
}
.button--danger {
background: var(--accent-warm);
border-color: var(--accent-warm);
color: var(--ink);
}
.button--danger:hover {
border-color: #6b6b6b;
background: #4f4f4f;
}
.button--save {
margin-left: auto;
}
.prompt-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding: 0;
min-height: 0;
}
.prompt-list__inner {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 16px 16px 16px;
}
.prompt-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
text-align: left;
position: relative;
border-radius: 14px;
border: 1px solid transparent;
background: var(--bg-elevated);
padding: 12px 48px 12px 14px;
cursor: pointer;
transition: border-color 0.15s ease;
}
.prompt-item:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.prompt-item.is-active {
border-color: #555;
background: #202020;
}
.prompt-item__content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.prompt-item__delete {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute;
top: 8px;
right: 8px;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.prompt-item__delete:hover {
border-color: transparent;
background: transparent;
color: var(--ink);
}
.prompt-item__title {
font-weight: 600;
overflow-wrap: anywhere;
word-break: break-word;
}
.prompt-item__meta {
font-size: 0.8rem;
color: var(--muted);
}
.workspace {
padding: 28px 32px;
height: 100%;
overflow: hidden;
}
.workspace__content {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
height: 100%;
min-height: 0;
}
.workspace__content--history {
grid-template-columns: minmax(0, 1fr) 260px;
}
.editor {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
border-radius: var(--radius);
background: var(--bg-elevated);
box-shadow: var(--shadow);
min-height: 0;
height: 100%;
}
.editor__header {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor__status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title-input {
font-size: 1.6rem;
border: none;
border-bottom: 2px solid var(--border);
padding: 6px 2px 8px;
background: transparent;
color: var(--ink);
}
.title-input:focus {
outline: none;
border-bottom-color: #5c5c5c;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.icon-button__img {
width: 18px;
height: 18px;
display: block;
}
.icon-button:hover {
border-color: rgba(255, 255, 255, 0.2);
color: var(--ink);
}
.icon-button.is-active {
border-color: #5c5c5c;
color: var(--ink);
background: #202020;
}
.status {
width: 10px;
height: 10px;
border-radius: 999px;
background: #3c3c3c;
}
.status--saved {
background: #b3b3b3;
}
.status--draft {
background: #8f8f8f;
}
.status--unsaved {
background: #d0d0d0;
}
.status--history {
background: #707070;
}
.editor__textarea {
flex: 1;
width: 100%;
resize: none;
border-radius: 12px;
border: 1px solid var(--border);
padding: 14px;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 0.95rem;
background: #151515;
color: var(--ink);
}
.editor__textarea:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.18);
}
.editor__footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: #1f1f1f;
}
.banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.banner--draft {
border-color: #3a3a3a;
background: #1d1d1d;
}
.banner--history {
border-color: #3a3a3a;
background: #1d1d1d;
}
.history {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
background: var(--panel);
border-radius: var(--radius);
border: 1px solid var(--border);
height: 100%;
min-height: 0;
}
.history__header {
display: flex;
align-items: center;
justify-content: space-between;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.95rem;
}
.history__close {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s ease;
}
.history__close:hover {
color: var(--ink);
}
.history__list {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.history__item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
text-align: left;
position: relative;
border: 1px solid transparent;
border-radius: 12px;
background: #1f1f1f;
padding: 10px 42px 10px 12px;
cursor: pointer;
transition: border-color 0.15s ease;
}
.history__item.is-active {
border-color: #555;
background: #242424;
}
.history__item-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.history__item-delete {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute;
top: 6px;
right: 6px;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.history__item-delete:hover {
border-color: transparent;
background: transparent;
color: var(--ink);
}
.modal {
position: fixed;
inset: 0;
display: grid;
place-items: center;
z-index: 20;
}
.modal__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
}
.modal__card {
position: relative;
z-index: 1;
width: min(420px, 92vw);
padding: 22px;
border-radius: 16px;
background: var(--bg-elevated);
border: 1px solid var(--border);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal__title {
font-size: 1.2rem;
}
.modal__message {
color: var(--muted);
}
.modal__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.history__item-title {
font-weight: 600;
}
.history__item-meta {
font-size: 0.8rem;
color: var(--muted);
}
.empty {
color: var(--muted);
font-size: 0.9rem;
}
.empty-state {
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-start;
padding: 32px;
border-radius: var(--radius);
background: var(--bg-elevated);
box-shadow: var(--shadow);
max-width: 520px;
}
.empty-state__title {
font-size: 1.6rem;
}
.empty-state__subtitle {
color: var(--muted);
}
@media (max-width: 900px) {
body {
height: auto;
overflow: auto;
}
#root {
height: auto;
}
.app {
grid-template-columns: 1fr;
height: auto;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
height: auto;
}
.workspace {
padding: 20px;
height: auto;
overflow: visible;
}
.workspace__content {
height: auto;
}
.workspace__content--history {
grid-template-columns: 1fr;
}
.editor {
height: auto;
}
.history {
height: auto;
}
}

255
src/lib/db.ts Normal file
View File

@@ -0,0 +1,255 @@
import Database from "@tauri-apps/plugin-sql";
export type Text = {
id: string;
title: string;
created_at: number;
updated_at: number;
last_saved_version_id: string | null;
};
export type TextVersion = {
id: string;
prompt_id: string;
body: string;
created_at: number;
kind: "manual" | "autosave";
note: string | null;
};
export type TextDraft = {
prompt_id: string;
body: string;
updated_at: number;
base_version_id: string | null;
};
const MIGRATIONS = [
"PRAGMA foreign_keys = ON;",
`CREATE TABLE IF NOT EXISTS prompts(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_saved_version_id TEXT
);`,
`CREATE TABLE IF NOT EXISTS prompt_versions(
id TEXT PRIMARY KEY,
prompt_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('manual','autosave')),
note TEXT,
FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS prompt_drafts(
prompt_id TEXT PRIMARY KEY,
body TEXT NOT NULL,
updated_at INTEGER NOT NULL,
base_version_id TEXT,
FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
);`,
"CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt_time ON prompt_versions(prompt_id, created_at DESC);",
"CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);"
];
let dbPromise: Promise<Database> | null = null;
async function migrate(db: Database) {
for (const statement of MIGRATIONS) {
await db.execute(statement);
}
}
export async function getDb(): Promise<Database> {
if (!dbPromise) {
dbPromise = (async () => {
const db = await Database.load("sqlite:text.db");
await migrate(db);
return db;
})();
}
return dbPromise;
}
export async function initDb() {
await getDb();
}
export async function listTexts(): Promise<Text[]> {
const db = await getDb();
const rows = await db.select<Text[]>(
"SELECT * FROM prompts ORDER BY updated_at DESC"
);
return rows;
}
export async function searchTexts(term: string): Promise<Text[]> {
const db = await getDb();
const normalized = `%${term.toLowerCase()}%`;
const rows = await db.select<Text[]>(
`SELECT p.*
FROM prompts p
WHERE LOWER(p.title) LIKE $1
OR EXISTS (
SELECT 1
FROM prompt_versions v
WHERE v.prompt_id = p.id
AND LOWER(v.body) LIKE $1
)
ORDER BY p.updated_at DESC`,
[normalized]
);
return rows;
}
export async function getText(promptId: string): Promise<Text | null> {
const db = await getDb();
const rows = await db.select<Text[]>(
"SELECT * FROM prompts WHERE id = $1 LIMIT 1",
[promptId]
);
return rows[0] ?? null;
}
export async function getLatestManualVersion(
promptId: string
): Promise<TextVersion | null> {
const db = await getDb();
const rows = await db.select<TextVersion[]>(
"SELECT * FROM prompt_versions WHERE prompt_id = $1 AND kind = 'manual' ORDER BY created_at DESC LIMIT 1",
[promptId]
);
return rows[0] ?? null;
}
export async function listVersions(
promptId: string
): Promise<TextVersion[]> {
const db = await getDb();
return db.select<TextVersion[]>(
"SELECT * FROM prompt_versions WHERE prompt_id = $1 ORDER BY created_at DESC",
[promptId]
);
}
export async function getDraft(promptId: string): Promise<TextDraft | null> {
const db = await getDb();
const rows = await db.select<TextDraft[]>(
"SELECT * FROM prompt_drafts WHERE prompt_id = $1 LIMIT 1",
[promptId]
);
return rows[0] ?? null;
}
export async function createText(
title: string,
body: string
): Promise<{ textId: string; versionId: string; createdAt: number }> {
const db = await getDb();
const now = Date.now();
const textId = crypto.randomUUID();
const versionId = crypto.randomUUID();
await db.execute(
"INSERT INTO prompts(id, title, created_at, updated_at, last_saved_version_id) VALUES($1, $2, $3, $4, $5)",
[textId, title, now, now, versionId]
);
await db.execute(
"INSERT INTO prompt_versions(id, prompt_id, body, created_at, kind, note) VALUES($1, $2, $3, $4, 'manual', NULL)",
[versionId, textId, body, now]
);
await db.execute("DELETE FROM prompt_drafts WHERE prompt_id = $1", [textId]);
return { textId, versionId, createdAt: now };
}
export async function saveManualVersion(
promptId: string,
title: string,
body: string
): Promise<{ versionId: string; savedAt: number }> {
const db = await getDb();
const now = Date.now();
const versionId = crypto.randomUUID();
await db.execute(
"INSERT INTO prompt_versions(id, prompt_id, body, created_at, kind, note) VALUES($1, $2, $3, $4, 'manual', NULL)",
[versionId, promptId, body, now]
);
await db.execute(
"UPDATE prompts SET title = $1, updated_at = $2, last_saved_version_id = $3 WHERE id = $4",
[title, now, versionId, promptId]
);
await db.execute("DELETE FROM prompt_drafts WHERE prompt_id = $1", [promptId]);
return { versionId, savedAt: now };
}
export async function updateTextTitle(
promptId: string,
title: string
) {
const db = await getDb();
const now = Date.now();
await db.execute("UPDATE prompts SET title = $1, updated_at = $2 WHERE id = $3", [
title,
now,
promptId
]);
}
export async function upsertDraft(
promptId: string,
body: string,
baseVersionId: string | null
) {
const db = await getDb();
const now = Date.now();
await db.execute(
`INSERT INTO prompt_drafts(prompt_id, body, updated_at, base_version_id)
VALUES($1, $2, $3, $4)
ON CONFLICT(prompt_id) DO UPDATE SET
body = excluded.body,
updated_at = excluded.updated_at,
base_version_id = excluded.base_version_id`,
[promptId, body, now, baseVersionId]
);
}
export async function discardDraft(promptId: string) {
const db = await getDb();
await db.execute("DELETE FROM prompt_drafts WHERE prompt_id = $1", [promptId]);
}
export async function deleteText(promptId: string) {
const db = await getDb();
await db.execute("DELETE FROM prompts WHERE id = $1", [promptId]);
}
export async function deleteTextVersion(promptId: string, versionId: string) {
const db = await getDb();
await db.execute("DELETE FROM prompt_versions WHERE id = $1", [versionId]);
const promptRows = await db.select<{ last_saved_version_id: string | null }[]>(
"SELECT last_saved_version_id FROM prompts WHERE id = $1 LIMIT 1",
[promptId]
);
const currentLastSaved = promptRows[0]?.last_saved_version_id ?? null;
if (currentLastSaved !== versionId) return;
const nextRows = await db.select<{ id: string }[]>(
"SELECT id FROM prompt_versions WHERE prompt_id = $1 AND kind = 'manual' ORDER BY created_at DESC LIMIT 1",
[promptId]
);
const nextId = nextRows[0]?.id ?? null;
await db.execute(
"UPDATE prompts SET last_saved_version_id = $1 WHERE id = $2",
[nextId, promptId]
);
}

24
src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { initDb } from "./lib/db";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
initDb()
.then(() => {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
})
.catch((error) => {
console.error("Failed to initialize database", error);
root.innerHTML =
'<div style="padding:24px;font-family:sans-serif;">Failed to start TextDB. Check the console for details.</div>';
});

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src"]
}

9
tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

17
vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => ({
plugins: [react()],
clearScreen: false,
server: {
port: 5173,
strictPort: true
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: "es2021",
minify: mode === "development" ? false : "esbuild",
sourcemap: mode === "development"
}
}));