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

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]);
}
}