Firts Commit: new app

This commit is contained in:
2026-04-14 15:44:14 +08:00
commit ade2b6ba16
11 changed files with 3545 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2545
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "memoscli"
version = "0.1.0"
edition = "2021"
authors = ["SidneyZhang<zly@lyzhang.me>"]
description = "A command-line tool to manage memos"
license = "MIT"
[dependencies]
ratatui = "0.28"
crossterm = "0.28"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
directories = "5.0"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-appender = "0.2"
clap = { version = "4.5", features = ["derive"] }
[profile.release]
opt-level = 3
lto = true

17
LICENSE Normal file
View File

@@ -0,0 +1,17 @@
Copyright 2026 SidneyZhang
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies
or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# MemosCLI
使用命令行管理自己部署的Memos。
[Memos](https://usememos.com/)

55
src/cli.rs Normal file
View File

@@ -0,0 +1,55 @@
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "memoscli")]
#[command(about = "A CLI tool to manage memos", long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Parser, Debug)]
pub enum Command {
Create(CreateCommand),
List(ListCommand),
Config(ConfigCommand),
}
#[derive(Parser, Debug)]
pub struct CreateCommand {
#[arg(short, long, help = "Content of the memo")]
pub content: Option<String>,
#[arg(short, long, help = "Markdown file to use as content")]
pub file: Option<PathBuf>,
#[arg(
short,
long,
default_value = "PRIVATE",
help = "Visibility: PUBLIC or PRIVATE"
)]
pub visibility: String,
}
#[derive(Parser, Debug)]
pub struct ListCommand {
#[arg(short, long, default_value = "20", help = "Number of memos to list")]
pub limit: i32,
#[arg(short, long, default_value = "0", help = "Offset for pagination")]
pub offset: i32,
}
#[derive(Parser, Debug)]
pub struct ConfigCommand {
#[arg(short, long, help = "Set the base URL")]
pub base_url: Option<String>,
#[arg(short, long, help = "Set the user token")]
pub token: Option<String>,
#[arg(short, long, action = clap::ArgAction::SetTrue, help = "Show current configuration")]
pub show: bool,
}

93
src/client.rs Normal file
View File

@@ -0,0 +1,93 @@
use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo};
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct MemosClient {
client: Client,
base_url: String,
user_token: String,
}
impl MemosClient {
pub fn new(base_url: &str, user_token: &str) -> Result<Self> {
let base_url = base_url.trim_end_matches('/');
Ok(Self {
client: Client::new(),
base_url: base_url.to_string(),
user_token: user_token.to_string(),
})
}
pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> {
let url = format!("{}/api/v1/memos", self.base_url);
let request = CreateMemoRequest {
content: content.to_string(),
visibility: visibility.map(|s| s.to_string()),
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.user_token))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to create memo: {} - {}", status, error_text);
}
let result: CreateMemoResponse = response.json().await?;
self.get_memo(result.id).await
}
pub async fn get_memo(&self, id: i64) -> Result<Memo> {
let url = format!("{}/api/v1/memos/{}", self.base_url, id);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.user_token))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to get memo: {} - {}", status, error_text);
}
Ok(response.json().await?)
}
pub async fn list_memos(&self, limit: Option<i32>, offset: Option<i32>) -> Result<Vec<Memo>> {
let url = format!("{}/api/v1/memos", self.base_url);
let mut request = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.user_token));
if let Some(l) = limit {
request = request.query(&[("limit", l.to_string())]);
}
if let Some(o) = offset {
request = request.query(&[("offset", o.to_string())]);
}
let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to list memos: {} - {}", status, error_text);
}
let result: ListMemosResponse = response.json().await?;
Ok(result.memos)
}
}

51
src/config.rs Normal file
View File

@@ -0,0 +1,51 @@
use anyhow::Result;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub base_url: String,
pub user_token: String,
}
impl Default for Config {
fn default() -> Self {
Self {
base_url: String::new(),
user_token: String::new(),
}
}
}
impl Config {
pub fn config_path() -> Result<PathBuf> {
let proj_dirs = ProjectDirs::from("com", "memoscli", "memoscli")
.ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
let config_dir = proj_dirs.config_dir();
fs::create_dir_all(config_dir)?;
Ok(config_dir.join("config.json"))
}
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(Config::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
let content = serde_json::to_string_pretty(self)?;
fs::write(path, content)?;
Ok(())
}
pub fn is_configured(&self) -> bool {
!self.base_url.is_empty() && !self.user_token.is_empty()
}
}

359
src/main.rs Normal file
View File

@@ -0,0 +1,359 @@
mod cli;
mod client;
mod config;
mod models;
mod tui;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Command};
use client::MemosClient;
use config::Config;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen};
use models::Memo;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io;
use tui::{App, AppMode};
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(command) = cli.command {
return run_cli_command(command).await;
}
run_tui_mode().await
}
async fn run_cli_command(command: Command) -> Result<()> {
let config = Config::load().unwrap_or_default();
if !config.is_configured() {
println!("Error: Memos is not configured. Please run 'memoscli config' first.");
return Ok(());
}
let client = MemosClient::new(&config.base_url, &config.user_token)?;
match command {
Command::Create(cmd) => {
let content = if let Some(file_path) = cmd.file {
if cmd.content.is_some() {
println!("Error: Cannot use both --content and --file options.");
return Ok(());
}
std::fs::read_to_string(&file_path)?
} else if let Some(c) = cmd.content {
c
} else {
println!("Error: Please provide either --content or --file option.");
return Ok(());
};
let visibility = if cmd.visibility.to_uppercase() == "PUBLIC" {
Some("PUBLIC")
} else {
Some("PRIVATE")
};
match client.create_memo(&content, visibility).await {
Ok(memo) => {
println!("Memo created successfully with ID: {}", memo.id);
}
Err(e) => {
println!("Error creating memo: {}", e);
}
}
}
Command::List(cmd) => {
match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
Ok(memos) => {
if memos.is_empty() {
println!("No memos found.");
} else {
for memo in &memos {
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
println!("[{}] {} - {}", memo.id, created, &memo.content[..memo.content.len().min(60)]);
}
println!("\nTotal: {} memos", memos.len());
}
}
Err(e) => {
println!("Error listing memos: {}", e);
}
}
}
Command::Config(cmd) => {
if cmd.show {
if config.is_configured() {
println!("Base URL: {}", config.base_url);
println!("User Token: ***");
} else {
println!("Not configured. Use --base-url and --token to configure.");
}
} else {
let mut new_config = config.clone();
if let Some(url) = cmd.base_url {
new_config.base_url = url;
}
if let Some(token) = cmd.token {
new_config.user_token = token;
}
new_config.save()?;
println!("Configuration saved.");
}
}
}
Ok(())
}
async fn run_tui_mode() -> Result<()> {
let config = Config::load().unwrap_or_default();
let mut app = App::new(config);
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.init_client().await?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
terminal.draw(|f| tui::render_app(f, &mut app))?;
if let Event::Key(key) = event::read()? {
match key.kind {
KeyEventKind::Press => {
if key.code == KeyCode::Char('q') && !app.loading {
if app.mode == AppMode::MainMenu {
break;
}
app.mode = AppMode::MainMenu;
app.message = None;
}
if key.code == KeyCode::Esc && !app.loading {
match app.mode {
AppMode::MainMenu => continue,
AppMode::Config => {
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::MainMenu;
}
AppMode::CreateMemo => {
app.input_content.clear();
app.input_visibility = "PRIVATE".to_string();
app.mode = AppMode::MainMenu;
}
AppMode::ListMemos => {
app.mode = AppMode::MainMenu;
}
AppMode::ViewMemo => {
app.mode = AppMode::ListMemos;
}
}
app.message = None;
}
match app.mode {
AppMode::MainMenu => {
match key.code {
KeyCode::Char('1') => {
if app.config.is_configured() {
app.loading = true;
terminal.draw(|f| tui::render_app(f, &mut app))?;
match load_memos(&app).await {
Ok(memos) => {
app.memos = memos;
app.mode = AppMode::ListMemos;
app.message = Some("Memos loaded successfully".to_string());
}
Err(e) => {
app.message = Some(format!("Error: {}", e));
}
}
app.loading = false;
} else {
app.message = Some("Please configure first".to_string());
}
}
KeyCode::Char('2') => {
if app.config.is_configured() {
app.input_content.clear();
app.input_visibility = "PRIVATE".to_string();
app.mode = AppMode::CreateMemo;
} else {
app.message = Some("Please configure first".to_string());
}
}
KeyCode::Char('3') => {
app.input_base_url = app.config.base_url.clone();
app.input_token = app.config.user_token.clone();
app.mode = AppMode::Config;
}
KeyCode::Char('Q') => {
break;
}
_ => {}
}
}
AppMode::Config => {
handle_config_input(&mut app, &key).await?;
}
AppMode::CreateMemo => {
handle_create_memo_input(&mut app, &key).await?;
}
AppMode::ListMemos => {
handle_list_memos_input(&mut app, &key).await?;
}
AppMode::ViewMemo => {}
}
}
_ => {}
}
}
}
disable_raw_mode()?;
terminal.show_cursor()?;
Ok(())
}
async fn load_memos(app: &App) -> Result<Vec<Memo>> {
let client = app.get_client().await?;
let memos = client.list_memos(None, None).await?;
Ok(memos)
}
async fn handle_config_input(app: &mut App, key: &KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char(c) => {
if app.current_field == 0 {
app.input_base_url.push(c);
} else {
app.input_token.push(c);
}
}
KeyCode::Backspace => {
if app.current_field == 0 {
if !app.input_base_url.is_empty() {
app.input_base_url.pop();
}
} else {
if !app.input_token.is_empty() {
app.input_token.pop();
}
}
}
KeyCode::Enter => {
app.config.base_url = app.input_base_url.clone();
app.config.user_token = app.input_token.clone();
app.config.save()?;
app.refresh_client().await?;
app.mode = AppMode::MainMenu;
app.message = Some("Configuration saved".to_string());
}
KeyCode::Tab => {
app.current_field = if app.current_field == 0 { 1 } else { 0 };
}
_ => {}
}
Ok(())
}
async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char(c) => {
app.input_content.push(c);
}
KeyCode::Backspace => {
app.input_content.pop();
}
KeyCode::Enter => {
if key.modifiers.contains(event::KeyModifiers::CONTROL) {
return Ok(());
}
if app.input_content.is_empty() {
app.message = Some("Content cannot be empty".to_string());
return Ok(());
}
let client = app.get_client().await?;
let visibility = if app.input_visibility.to_uppercase() == "PUBLIC" {
Some("PUBLIC")
} else {
Some("PRIVATE")
};
match client.create_memo(&app.input_content, visibility).await {
Ok(memo) => {
app.message = Some(format!("Memo created with ID: {}", memo.id));
app.input_content.clear();
app.input_visibility = "PRIVATE".to_string();
app.mode = AppMode::MainMenu;
}
Err(e) => {
app.message = Some(format!("Error: {}", e));
}
}
}
_ => {}
}
Ok(())
}
async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => {
let client = app.get_client().await?;
match client.list_memos(None, None).await {
Ok(memos) => {
app.memos = memos;
app.message = Some("Memos refreshed".to_string());
}
Err(e) => {
app.message = Some(format!("Error: {}", e));
}
}
}
KeyCode::Enter => {
if !app.memos.is_empty() {
if let Some(idx) = app.list_selected {
if idx < app.memos.len() {
app.selected_memo = Some(app.memos[idx].clone());
app.mode = AppMode::ViewMemo;
}
}
}
}
KeyCode::Up => {
if let Some(idx) = app.list_selected {
if idx > 0 {
app.list_selected = Some(idx - 1);
}
} else {
app.list_selected = Some(0);
}
}
KeyCode::Down => {
if let Some(idx) = app.list_selected {
if idx < app.memos.len() - 1 {
app.list_selected = Some(idx + 1);
}
} else if !app.memos.is_empty() {
app.list_selected = Some(0);
}
}
_ => {}
}
Ok(())
}

42
src/models.rs Normal file
View File

@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMemoRequest {
pub content: String,
#[serde(rename = "visibility")]
pub visibility: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memo {
pub id: i64,
pub creator_id: i64,
pub created_ts: i64,
pub updated_ts: i64,
pub content: String,
pub visibility: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub resources: Vec<Resource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resource {
pub id: i64,
pub filename: String,
#[serde(rename = "type")]
pub resource_type: String,
pub size: i64,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMemosResponse {
pub memos: Vec<Memo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMemoResponse {
pub id: i64,
}

351
src/tui.rs Normal file
View File

@@ -0,0 +1,351 @@
use crate::config::Config;
use crate::client::MemosClient;
use crate::models::Memo;
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
MainMenu,
Config,
CreateMemo,
ListMemos,
ViewMemo,
}
pub struct App {
pub mode: AppMode,
pub config: Config,
pub memos: Vec<Memo>,
pub selected_memo: Option<Memo>,
pub input_content: String,
pub input_visibility: String,
pub input_base_url: String,
pub input_token: String,
pub current_field: usize,
pub message: Option<String>,
pub loading: bool,
client: Arc<Mutex<Option<MemosClient>>>,
pub list_selected: Option<usize>,
}
impl App {
pub fn new(config: Config) -> Self {
Self {
mode: AppMode::MainMenu,
config,
memos: Vec::new(),
selected_memo: None,
input_content: String::new(),
input_visibility: "PRIVATE".to_string(),
input_base_url: String::new(),
input_token: String::new(),
current_field: 0,
message: None,
loading: false,
client: Arc::new(Mutex::new(None)),
list_selected: None,
}
}
pub async fn init_client(&self) -> Result<()> {
if self.config.is_configured() {
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
let mut guard = self.client.lock().await;
*guard = Some(client);
}
Ok(())
}
pub async fn refresh_client(&mut self) -> Result<()> {
if self.config.is_configured() {
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
let mut guard = self.client.lock().await;
*guard = Some(client);
}
Ok(())
}
pub async fn get_client(&self) -> Result<MemosClient> {
let guard = self.client.lock().await;
guard.clone().ok_or_else(|| anyhow::anyhow!("Client not initialized"))
}
}
pub fn render_app(frame: &mut Frame, app: &mut App) {
match app.mode {
AppMode::MainMenu => render_main_menu(frame, app),
AppMode::Config => render_config(frame, app),
AppMode::CreateMemo => render_create_memo(frame, app),
AppMode::ListMemos => render_list_memos(frame, app),
AppMode::ViewMemo => render_view_memo(frame, app),
}
if let Some(msg) = &app.message {
let area = Rect::new(10, frame.area().height - 3, frame.area().width - 10, frame.area().height - 1);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Yellow));
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let text = Paragraph::new(msg.as_str())
.alignment(Alignment::Center);
frame.render_widget(text, area);
}
if app.loading {
let area = Rect::new(
frame.area().width / 2 - 10,
frame.area().height / 2 - 3,
frame.area().width / 2 + 10,
frame.area().height / 2 + 3,
);
let block = Block::default()
.borders(Borders::ALL)
.title("Loading...")
.title_alignment(Alignment::Center);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
}
}
fn render_main_menu(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Memos CLI")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Welcome"));
frame.render_widget(title, chunks[0]);
let items = vec![
("1", "List Memos"),
("2", "Create Memo"),
("3", "Configuration"),
("Q", "Quit"),
];
let menu_items: Vec<ListItem> = items
.iter()
.map(|(key, desc)| {
let key_str = *key;
let desc_str = *desc;
ListItem::new(Line::from(vec![
Span::raw("["),
Span::raw(key_str).style(Style::default().fg(Color::Yellow).bold()),
Span::raw("] "),
Span::raw(desc_str),
]))
})
.collect();
let menu = List::new(menu_items)
.block(Block::default().borders(Borders::ALL).title("Menu"));
frame.render_widget(menu, chunks[1]);
let status = if app.config.is_configured() {
format!("Connected to: {}", app.config.base_url)
} else {
"Not configured".to_string()
};
let status_bar = Paragraph::new(status)
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center);
frame.render_widget(status_bar, chunks[2]);
}
fn render_config(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Configuration")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Settings"));
frame.render_widget(title, chunks[0]);
let base_url_label = Paragraph::new("Base URL:")
.style(Style::default().fg(Color::White))
.alignment(Alignment::Right);
frame.render_widget(base_url_label, chunks[1]);
let base_url_input = Paragraph::new(app.input_base_url.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Base URL"));
frame.render_widget(base_url_input, chunks[1]);
let token_label = Paragraph::new("User Token:")
.style(Style::default().fg(Color::White))
.alignment(Alignment::Right);
frame.render_widget(token_label, chunks[2]);
let token_input = Paragraph::new(app.input_token.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Token"));
frame.render_widget(token_input, chunks[2]);
let help = Paragraph::new("Enter: Save | Esc: Cancel | Tab: Switch Field")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[5]);
}
fn render_create_memo(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Create Memo")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("New Memo"));
frame.render_widget(title, chunks[0]);
let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):")
.style(Style::default().fg(Color::White));
frame.render_widget(visibility_label, chunks[1]);
let visibility_input = Paragraph::new(app.input_visibility.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Visibility"));
frame.render_widget(visibility_input, chunks[1]);
let content_label = Paragraph::new("Content:")
.style(Style::default().fg(Color::White));
frame.render_widget(content_label, chunks[2]);
let content = Paragraph::new(app.input_content.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Content"));
frame.render_widget(content, chunks[2]);
let help = Paragraph::new("Enter: Submit | Esc: Cancel")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
fn render_list_memos(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Memos List")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Memos"));
frame.render_widget(title, chunks[0]);
let memos_list: Vec<ListItem> = app.memos.iter().enumerate().map(|(idx, memo)| {
let content_preview = if memo.content.len() > 50 {
format!("{}...", &memo.content[..50])
} else {
memo.content.clone()
};
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let style = if app.list_selected == Some(idx) {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::raw(format!("[{}] ", memo.id)),
Span::styled(content_preview, Style::default().fg(Color::White)),
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
Span::raw(created).style(Style::default().fg(Color::Green)),
])).style(style)
}).collect();
let list = List::new(memos_list)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(list, chunks[1]);
let help = Paragraph::new("Enter: View | R: Refresh | Esc: Back | ↑↓: Navigate")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_view_memo(frame: &mut Frame, app: &App) {
if let Some(memo) = &app.selected_memo {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new(format!("Memo #{}", memo.id))
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("View Memo"));
frame.render_widget(title, chunks[0]);
let meta = format!(
"Created: {} | Updated: {} | Visibility: {}",
chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "Unknown".to_string()),
chrono::DateTime::from_timestamp_millis(memo.updated_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "Unknown".to_string()),
memo.visibility
);
let meta_para = Paragraph::new(meta)
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center);
frame.render_widget(meta_para, chunks[1]);
let content = Paragraph::new(memo.content.clone())
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL).title("Content"));
frame.render_widget(content, chunks[2]);
let help = Paragraph::new("Esc: Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
}