feat: initial commit - terminal note app with SQLite
This commit is contained in:
171
src/components/NoteEditor.tsx
Normal file
171
src/components/NoteEditor.tsx
Normal 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
123
src/components/NoteList.tsx
Normal 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
86
src/db.ts
Normal 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();
|
||||
106
src/index.tsx
106
src/index.tsx
@@ -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 />);
|
||||
Reference in New Issue
Block a user