initial commit
This commit is contained in:
54
README.md
Normal file
54
README.md
Normal 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
12
index.html
Normal 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
27
package.json
Normal 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
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
16
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
16
src-tauri/capabilities/main.json
Normal file
16
src-tauri/capabilities/main.json
Normal 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
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
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
10
src-tauri/src/main.rs
Normal 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
35
src-tauri/tauri.conf.json
Normal 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
782
src/App.tsx
Normal 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
BIN
src/assets/history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
613
src/index.css
Normal file
613
src/index.css
Normal 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
255
src/lib/db.ts
Normal 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
24
src/main.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal 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
9
tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
17
vite.config.ts
Normal file
17
vite.config.ts
Normal 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"
|
||||||
|
}
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user