feat: initial commit - terminal note app with SQLite

This commit is contained in:
2026-04-11 01:24:17 +08:00
parent 3e05fd06fe
commit 6e2c50ca30
9 changed files with 653 additions and 11 deletions

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useRef } from "react";
import { db, type Note } from "../db";
interface NoteEditorProps {
note: Note | null;
onUpdate: () => void;
onDelete: (id: number) => void;
}
function renderMarkdown(content: string): any {
const lines = content.split('\n');
const elements: any[] = [];
let inCodeBlock = false;
const codeLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (!inCodeBlock) {
inCodeBlock = true;
codeLines.length = 0;
} else {
for (let j = 0; j < codeLines.length; j++) {
elements.push(<text bg="#1f2335" fg="#9ece6a">{codeLines[j]}</text>);
}
inCodeBlock = false;
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith('# ')) {
elements.push(<text fg="#7aa2f7" bold>{trimmed.substring(2)}</text>);
} else if (trimmed.startsWith('## ')) {
elements.push(<text fg="#bb9af7" bold>{trimmed.substring(3)}</text>);
} else if (trimmed.startsWith('### ')) {
elements.push(<text fg="#9ece6a" bold>{trimmed.substring(4)}</text>);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
elements.push(<text fg="#ff7b72"> {trimmed.substring(2)}</text>);
} else if (trimmed.startsWith('> ')) {
elements.push(<text fg="#565f89"> {trimmed.substring(2)}</text>);
} else if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
elements.push(<text fg="#414868"></text>);
} else if (!trimmed) {
elements.push(<text>{" "}</text>);
} else {
elements.push(<text>{trimmed}</text>);
}
}
return elements;
}
export function NoteEditor({ note, onUpdate, onDelete }: NoteEditorProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const textareaRef = useRef<{ plainText: string } | null>(null);
useEffect(() => {
if (note) {
setTitle(note.title);
setContent(note.content);
} else {
setTitle("");
setContent("");
}
setIsEditing(false);
}, [note?.id]);
const handleSave = async () => {
if (note) {
const finalContent = textareaRef.current?.plainText ?? content;
await db.updateNote(note.id, title, finalContent);
setContent(finalContent);
onUpdate();
setIsEditing(false);
}
};
if (!note) {
return (
<box flexGrow={1} justifyContent="center" alignItems="center">
<text fg="#565f89">Select a note or create a new one</text>
</box>
);
}
const displayContent = content || "* No content yet. Click Edit to start writing. *";
// ---------------------------------------------------
// 这下面的组件ai只读嵌套层级不许更改
return (
<box flexDirection="column" flexGrow={1}>
<box
paddingX={2}
paddingY={1}
backgroundColor="#1f2335"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
width="100%"
>
{isEditing ? (
<box width="90%">
<input
value={title}
onChange={setTitle}
backgroundColor="transparent"
textColor="#e0af68"
width="100%"
/>
</box>
) : (
<box>
<text fg="#e0af68">
<strong>{title || "Untitled"}</strong>
</text>
</box>
)}
<box flexDirection="row" gap={1} height={"100%"}>
<box backgroundColor={isEditing ? "#9ece6a" : "#7aa2f7"}>
<text onMouseDown={() => isEditing ? handleSave() : setIsEditing(true)}>
{isEditing ? "[保存]" : "[编辑]"}
</text>
</box>
<box backgroundColor="#f7768e">
<text onMouseDown={() => note && onDelete(note.id)}>
[]
</text>
</box>
</box>
</box>
{/* // 这上面的组件ai只读嵌套层级不许更改 */}
<box flexGrow={1} overflow="hidden" backgroundColor="#1a1b2600">
{isEditing ? (
/* // 这下面的组件ai只读嵌套层级不许更改 */
<box padding={2}>
<textarea
initialValue={content}
backgroundColor="#1a1b2600"
textColor="#c0caf5"
placeholderColor="#565f89"
placeholder="Write in Markdown..."
width="100%"
height="100%"
focused
ref={(el) => { textareaRef.current = el as unknown as { plainText: string } | null; }}
/>
</box>
// 这上面的组件ai只读嵌套层级不许更改
) : (
<scrollbox focused stickyScroll height={"100%"}>
<box flexDirection="column" padding={2}>
{renderMarkdown(displayContent)}
</box>
</scrollbox>
)}
</box>
</box>
);
}

123
src/components/NoteList.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { useState } from "react";
import type { Note } from "../db";
interface NoteListProps {
notes: Note[];
selectedId: number | null;
selectedIds: Set<number>;
onSelect: (id: number) => void;
onMultiSelect: (ids: number[]) => void;
onToggleSelect: (id: number) => void;
onCreate: () => void;
onDeleteSelected: () => void;
}
export function NoteList({ notes, selectedId, selectedIds, onSelect, onMultiSelect, onToggleSelect, onCreate, onDeleteSelected }: NoteListProps) {
const [lastSelectedId, setLastSelectedId] = useState<number | null>(null);
const [selectionMode, setSelectionMode] = useState(false);
const handleClick = (id: number) => {
if (selectionMode) {
onToggleSelect(id);
} else if (selectedIds.size > 0 && lastSelectedId !== null) {
const startIdx = notes.findIndex(n => n.id === lastSelectedId);
const endIdx = notes.findIndex(n => n.id === id);
const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
const rangeIds = notes.slice(from, to + 1).map(n => n.id);
onMultiSelect(rangeIds);
} else {
onSelect(id);
}
setLastSelectedId(id);
};
const handleDoubleClick = (id: number) => {
setSelectionMode(!selectionMode);
};
const hasMultipleSelected = selectedIds.size > 1;
return (
<box flexDirection="column" flexGrow={1} paddingX={1}>
<box flexDirection="row" alignItems="center" marginBottom={1}>
<box
paddingX={2}
paddingY={1}
backgroundColor="#1f2335"
onMouseDown={() => onCreate()}
>
<text>
<strong fg="#5381e48e">+ </strong>
</text>
</box>
{hasMultipleSelected && (
<box paddingX={2} paddingY={1} backgroundColor="#f7768e" marginLeft={1} onMouseDown={() => onDeleteSelected()}>
<text>
({selectedIds.size})
</text>
</box>
)}
</box>
<box flexDirection="column" flexGrow={1} overflow="scroll">
<scrollbox focused stickyScroll height={"100%"}>
{notes.map((note) => (
<NoteItem
key={note.id}
note={note}
isSelected={note.id === selectedId}
isMultiSelected={selectedIds.has(note.id)}
selectionMode={selectionMode}
onClick={() => handleClick(note.id)}
onDoubleClick={() => handleDoubleClick(note.id)}
/>
))}
</scrollbox>
</box>
</box>
);
}
interface NoteItemProps {
note: Note;
isSelected: boolean;
isMultiSelected: boolean;
selectionMode: boolean;
onClick: () => void;
onDoubleClick: () => void;
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
function NoteItem({ note, isSelected, isMultiSelected, selectionMode, onClick, onDoubleClick }: NoteItemProps) {
const isActive = isSelected || isMultiSelected;
const bgColor = isActive ? "#414868" : "transparent";
const fgColor = isActive ? "#c0caf5" : "#a9b1d6";
const date = new Date(note.updatedAt);
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
const displayTitle = truncateText(note.title || "Untitled", 10);
return (
<box
paddingX={2}
paddingY={1}
backgroundColor={bgColor}
onMouseDown={() => onClick()}
onMouseDownCapture={() => onDoubleClick()}
width={"100%"}
flexDirection="column"
>
<box flexGrow={1} width={"100%"} >
<text fg={fgColor}>
<strong>{displayTitle}</strong>
</text>
</box>
<box flexGrow={1} width={"100%"} paddingTop={1}>
<text fg="#565f89"> {timeStr} · {dateStr}</text>
</box>
</box>
);
}

86
src/db.ts Normal file
View File

@@ -0,0 +1,86 @@
import { createClient } from "@libsql/client";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
function getDbPath(): string {
const homeDir = os.homedir();
const configDir = path.join(homeDir, ".config", "mio");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return path.join(configDir, "cache.db");
}
export interface Note {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
}
class Database {
private client: ReturnType<typeof createClient>;
constructor() {
const dbPath = getDbPath();
this.client = createClient({
url: `file:${dbPath}`,
});
this.init();
}
private async init() {
await this.client.execute(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
async getAllNotes(): Promise<Note[]> {
const result = await this.client.execute(
"SELECT id, title, content, created_at as createdAt, updated_at as updatedAt FROM notes ORDER BY updated_at DESC"
);
return result.rows as unknown as Note[];
}
async getNote(id: number): Promise<Note | null> {
const result = await this.client.execute({
sql: "SELECT id, title, content, created_at as createdAt, updated_at as updatedAt FROM notes WHERE id = ?",
args: [id],
});
return (result.rows[0] as unknown as Note) || null;
}
async createNote(title: string = "Untitled", content: string = ""): Promise<Note> {
const result = await this.client.execute({
sql: "INSERT INTO notes (title, content) VALUES (?, ?) RETURNING id, title, content, created_at as createdAt, updated_at as updatedAt",
args: [title, content],
});
return result.rows[0] as unknown as Note;
}
async updateNote(id: number, title: string, content: string): Promise<void> {
await this.client.execute({
sql: "UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?",
args: [title, content, id],
});
}
async deleteNote(id: number): Promise<void> {
await this.client.execute({
sql: "DELETE FROM notes WHERE id = ?",
args: [id],
});
}
}
export const db = new Database();

View File

@@ -1,16 +1,108 @@
import { createCliRenderer, TextAttributes } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { useState, useEffect } from "react";
import { createCliRenderer } from "@opentui/core";
import { createRoot, useKeyboard } from "@opentui/react";
import { db, type Note } from "./db";
import { NoteList } from "./components/NoteList";
import { NoteEditor } from "./components/NoteEditor";
function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
loadNotes();
}, [refreshKey]);
const loadNotes = async () => {
const allNotes = await db.getAllNotes();
setNotes(allNotes);
};
const handleSelect = (id: number) => {
setSelectedId(id);
setSelectedIds(new Set([id]));
};
const handleMultiSelect = (ids: number[]) => {
setSelectedIds(new Set(ids));
const lastId = ids[ids.length - 1];
if (lastId !== undefined) {
setSelectedId(lastId);
}
};
const handleToggleSelect = (id: number) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
if (newSelected.size > 0) {
setSelectedId(id);
}
};
const handleCreate = async () => {
const newNote = await db.createNote();
setSelectedId(newNote.id);
setSelectedIds(new Set([newNote.id]));
setRefreshKey((k) => k + 1);
};
const handleUpdate = () => {
setRefreshKey((k) => k + 1);
};
const handleDelete = async (id: number) => {
await db.deleteNote(id);
if (selectedId === id) {
setSelectedId(null);
setSelectedIds(new Set());
}
setRefreshKey((k) => k + 1);
};
const handleDeleteSelected = async () => {
for (const id of selectedIds) {
await db.deleteNote(id);
}
setSelectedId(null);
setSelectedIds(new Set());
setRefreshKey((k) => k + 1);
};
const selectedNote = selectedId ? notes.find((n) => n.id === selectedId) : null;
useKeyboard((key) => {
if (key.name === "escape") {
process.exit(0);
}
});
return (
<box alignItems="center" justifyContent="center" flexGrow={1}>
<box justifyContent="center" alignItems="flex-end">
<ascii-font font="tiny" text="OpenTUI" />
<text attributes={TextAttributes.DIM}>What will you build?</text>
<box flexDirection="row" flexGrow={1} padding={1} backgroundColor="#1a1b2600">
<box width={35} marginRight={1}>
<NoteList
notes={notes}
selectedId={selectedId}
selectedIds={selectedIds}
onSelect={handleSelect}
onMultiSelect={handleMultiSelect}
onToggleSelect={handleToggleSelect}
onCreate={handleCreate}
onDeleteSelected={handleDeleteSelected}
/>
</box>
<box flexGrow={1}>
<NoteEditor note={selectedNote || null} onUpdate={handleUpdate} onDelete={handleDelete} />
</box>
</box>
);
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);
createRoot(renderer).render(<App />);