345 lines
9.1 KiB
Rust
345 lines
9.1 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
// ─── Domain types ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Space {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Page {
|
|
pub id: String,
|
|
pub title: Option<String>,
|
|
pub icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DataWrapper<T> {
|
|
pub items: Vec<T>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum ApiList<T> {
|
|
Wrapped { data: DataWrapper<T> },
|
|
Paginated { items: Vec<T> },
|
|
Plain(Vec<T>),
|
|
}
|
|
|
|
impl<T> ApiList<T> {
|
|
pub fn into_items(self) -> Vec<T> {
|
|
match self {
|
|
ApiList::Wrapped { data } => data.items,
|
|
ApiList::Paginated { items } => items,
|
|
ApiList::Plain(v) => v,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── App state ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum AppState {
|
|
Login,
|
|
Main,
|
|
Editor,
|
|
Search,
|
|
}
|
|
|
|
// ─── Login form ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum LoginField {
|
|
Url,
|
|
Email,
|
|
Password,
|
|
}
|
|
|
|
impl LoginField {
|
|
pub fn next(self) -> Self {
|
|
match self {
|
|
LoginField::Url => LoginField::Email,
|
|
LoginField::Email => LoginField::Password,
|
|
LoginField::Password => LoginField::Url,
|
|
}
|
|
}
|
|
pub fn prev(self) -> Self {
|
|
match self {
|
|
LoginField::Url => LoginField::Password,
|
|
LoginField::Email => LoginField::Url,
|
|
LoginField::Password => LoginField::Email,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct LoginForm {
|
|
pub url: String,
|
|
pub email: String,
|
|
pub password: String,
|
|
pub active_field: LoginField,
|
|
pub error: Option<String>,
|
|
pub submitting: bool,
|
|
}
|
|
|
|
impl LoginForm {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
url: std::env::var("DOCMOST_URL")
|
|
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
|
|
email: std::env::var("DOCMOST_EMAIL").unwrap_or_default(),
|
|
password: String::new(),
|
|
active_field: if std::env::var("DOCMOST_EMAIL").is_ok() {
|
|
LoginField::Password
|
|
} else {
|
|
LoginField::Email
|
|
},
|
|
error: None,
|
|
submitting: false,
|
|
}
|
|
}
|
|
|
|
pub fn active_field_value_mut(&mut self) -> &mut String {
|
|
match self.active_field {
|
|
LoginField::Url => &mut self.url,
|
|
LoginField::Email => &mut self.email,
|
|
LoginField::Password => &mut self.password,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Main view ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum Panel {
|
|
Spaces,
|
|
Pages,
|
|
}
|
|
|
|
pub struct NewPageDialog {
|
|
pub title: String,
|
|
pub creating: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl NewPageDialog {
|
|
pub fn new() -> Self {
|
|
Self { title: String::new(), creating: false, error: None }
|
|
}
|
|
}
|
|
|
|
pub struct NewSpaceDialog {
|
|
pub name: String,
|
|
pub creating: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl NewSpaceDialog {
|
|
pub fn new() -> Self {
|
|
Self { name: String::new(), creating: false, error: None }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum DeleteTarget {
|
|
Space { id: String, name: String },
|
|
Page { id: String, title: String },
|
|
}
|
|
|
|
impl DeleteTarget {
|
|
pub fn display_name(&self) -> &str {
|
|
match self {
|
|
DeleteTarget::Space { name, .. } => name,
|
|
DeleteTarget::Page { title, .. } => title,
|
|
}
|
|
}
|
|
pub fn kind(&self) -> &str {
|
|
match self {
|
|
DeleteTarget::Space { .. } => "space",
|
|
DeleteTarget::Page { .. } => "page",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct DeleteConfirmDialog {
|
|
pub target: DeleteTarget,
|
|
pub deleting: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl DeleteConfirmDialog {
|
|
pub fn new(target: DeleteTarget) -> Self {
|
|
Self { target, deleting: false, error: None }
|
|
}
|
|
}
|
|
|
|
pub struct MainView {
|
|
pub spaces: Vec<Space>,
|
|
pub pages: Vec<Page>,
|
|
pub selected_space: usize,
|
|
pub selected_page: usize,
|
|
pub focus: Panel,
|
|
pub loading_spaces: bool,
|
|
pub loading_pages: bool,
|
|
pub loading_page_content: bool,
|
|
pub error: Option<String>,
|
|
pub new_page_dialog: Option<NewPageDialog>,
|
|
pub new_space_dialog: Option<NewSpaceDialog>,
|
|
pub delete_dialog: Option<DeleteConfirmDialog>,
|
|
}
|
|
|
|
impl MainView {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
spaces: vec![],
|
|
pages: vec![],
|
|
selected_space: 0,
|
|
selected_page: 0,
|
|
focus: Panel::Spaces,
|
|
loading_spaces: true,
|
|
loading_pages: false,
|
|
loading_page_content: false,
|
|
error: None,
|
|
new_page_dialog: None,
|
|
new_space_dialog: None,
|
|
delete_dialog: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Editor view ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum EditorFocus {
|
|
Title,
|
|
Content,
|
|
}
|
|
|
|
pub struct EditorView {
|
|
pub page_id: String,
|
|
pub page_title: String,
|
|
pub focus: EditorFocus,
|
|
pub saving: bool,
|
|
pub status: Option<EditorStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum EditorStatus {
|
|
Saved,
|
|
Error(String),
|
|
}
|
|
|
|
impl EditorView {
|
|
pub fn new(page_id: String, page_title: String) -> Self {
|
|
Self {
|
|
page_id,
|
|
page_title,
|
|
focus: EditorFocus::Content,
|
|
saving: false,
|
|
status: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Search view ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct SearchResultSpace {
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct SearchResult {
|
|
pub id: String,
|
|
pub title: Option<String>,
|
|
pub icon: Option<String>,
|
|
pub highlight: Option<String>,
|
|
pub space: Option<SearchResultSpace>,
|
|
}
|
|
|
|
pub struct SearchView {
|
|
pub query: String,
|
|
pub results: Vec<SearchResult>,
|
|
pub selected: usize,
|
|
pub loading: bool,
|
|
pub error: Option<String>,
|
|
pub opening_page: bool,
|
|
}
|
|
|
|
impl SearchView {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
query: String::new(),
|
|
results: vec![],
|
|
selected: 0,
|
|
loading: false,
|
|
error: None,
|
|
opening_page: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Root app ─────────────────────────────────────────────────────────────────
|
|
|
|
pub struct App {
|
|
pub state: AppState,
|
|
pub login: LoginForm,
|
|
pub main: MainView,
|
|
pub editor: EditorView,
|
|
pub search: SearchView,
|
|
pub base_url: String,
|
|
pub token: String,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
state: AppState::Login,
|
|
login: LoginForm::new(),
|
|
main: MainView::new(),
|
|
editor: EditorView::new(String::new(), String::new()),
|
|
search: SearchView::new(),
|
|
base_url: String::new(),
|
|
token: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Messages ─────────────────────────────────────────────────────────────────
|
|
|
|
pub enum AppMsg {
|
|
LoginSuccess { token: String, base_url: String },
|
|
LoginError(String),
|
|
SpacesLoaded(Vec<Space>),
|
|
PagesLoaded(Vec<Page>),
|
|
PageContentLoaded { page_id: String, title: String, content: String },
|
|
PageSaved,
|
|
SaveError(String),
|
|
PageCreated { page_id: String, title: String },
|
|
CreateError(String),
|
|
SpaceCreated(Space),
|
|
CreateSpaceError(String),
|
|
SpaceDeleted(String),
|
|
PageDeleted(String),
|
|
DeleteError(String),
|
|
SearchResults(Vec<SearchResult>),
|
|
SearchError(String),
|
|
ApiError(String),
|
|
}
|
|
|
|
// ─── Request DTOs ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LoginRequest<'a> {
|
|
pub email: &'a str,
|
|
pub password: &'a str,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SidebarPagesRequest<'a> {
|
|
#[serde(rename = "spaceId")]
|
|
pub space_id: &'a str,
|
|
}
|