new functions
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN editor_color VARCHAR(7) NULL;
|
||||||
|
|
||||||
|
CREATE TABLE user_resource_colors (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
resource_kind VARCHAR(32) NOT NULL,
|
||||||
|
resource_slug VARCHAR(255) NOT NULL,
|
||||||
|
color VARCHAR(7) NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, resource_kind, resource_slug),
|
||||||
|
CONSTRAINT fk_user_resource_colors_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN editor_color TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE user_resource_colors (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
resource_kind TEXT NOT NULL,
|
||||||
|
resource_slug TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, resource_kind, resource_slug)
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN editor_color TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE user_resource_colors (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL,
|
||||||
|
resource_slug TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, resource_kind, resource_slug),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
+254
-25
@@ -130,6 +130,13 @@ pub struct NoteInfo {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
|
global_color: Option<String>,
|
||||||
|
note_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EditorColorRequest {
|
||||||
|
color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_workspace(
|
pub async fn create_workspace(
|
||||||
@@ -234,8 +241,17 @@ pub async fn create_note(
|
|||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let title = validate_name(&payload.name, "Note name")?;
|
let title = validate_name(&payload.name, "Note name")?;
|
||||||
@@ -276,6 +292,90 @@ pub async fn create_note(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clean_editor_color(value: Option<&str>) -> Result<Option<String>, ApiError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
if value.len() == 7
|
||||||
|
&& value.starts_with('#')
|
||||||
|
&& value[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
Ok(Some(value.to_ascii_lowercase()))
|
||||||
|
} else {
|
||||||
|
Err(ApiError::bad_request("Invalid editor color"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn editor_colors(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<(Option<String>, Option<String>), ApiError> {
|
||||||
|
let Some(user) = crate::auth::optional_user(state, headers)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::forbidden(&e.message))?
|
||||||
|
else {
|
||||||
|
return Ok((None, None));
|
||||||
|
};
|
||||||
|
let global: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_EDITOR_COLOR_BY_USER,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let note: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::RESOURCE_COLOR_BY_USER,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok((global, note))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_editor_color(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let user = crate::auth::optional_user(state, headers)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::forbidden(&e.message))?
|
||||||
|
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
|
||||||
|
let color = clean_editor_color(color)?;
|
||||||
|
let mut tx = state.db.pool().begin().await?;
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::RESOURCE_COLOR_DELETE,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if let Some(value) = color.as_deref() {
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::RESOURCE_COLOR_INSERT,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(value)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Json(serde_json::json!({"color": color})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn note_info(
|
pub async fn note_info(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -295,6 +395,8 @@ pub async fn note_info(
|
|||||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let color_slug = format!("{}/{}", workspace_slug, note_slug);
|
||||||
|
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
|
||||||
|
|
||||||
Ok(Json(NoteInfo {
|
Ok(Json(NoteInfo {
|
||||||
workspace_slug: workspace.slug,
|
workspace_slug: workspace.slug,
|
||||||
@@ -327,6 +429,8 @@ pub async fn note_info(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
workspace_owner || note_owner
|
workspace_owner || note_owner
|
||||||
},
|
},
|
||||||
|
global_color,
|
||||||
|
note_color,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +478,17 @@ pub async fn restore(
|
|||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
|
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
|
||||||
@@ -407,7 +520,6 @@ pub async fn restore(
|
|||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum AccessLevel {
|
pub enum AccessLevel {
|
||||||
None,
|
None,
|
||||||
@@ -513,9 +625,8 @@ pub async fn authorized_workspace(
|
|||||||
let workspace = db::find_workspace(&state.db, slug)
|
let workspace = db::find_workspace(&state.db, slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_workspace)?;
|
.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
let token_level = combined_token_access_level(
|
let token_level =
|
||||||
state, "workspace", slug, access_token, bearer,
|
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
|
||||||
).await?;
|
|
||||||
if workspace.is_private != 0 && token_level == AccessLevel::None {
|
if workspace.is_private != 0 && token_level == AccessLevel::None {
|
||||||
return Err(ApiError::forbidden("This workspace is private."));
|
return Err(ApiError::forbidden("This workspace is private."));
|
||||||
}
|
}
|
||||||
@@ -536,7 +647,8 @@ async fn authorized_note(
|
|||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
bearer: Option<&str>,
|
bearer: Option<&str>,
|
||||||
) -> Result<(db::Workspace, db::Note), ApiError> {
|
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||||
let workspace = authorized_workspace(state, workspace_slug, password, access_token, bearer).await?;
|
let workspace =
|
||||||
|
authorized_workspace(state, workspace_slug, password, access_token, bearer).await?;
|
||||||
let note = db::find_note(&state.db, workspace.id, note_slug)
|
let note = db::find_note(&state.db, workspace.id, note_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
@@ -644,6 +756,8 @@ pub struct PadInfo {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
|
global_color: Option<String>,
|
||||||
|
note_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_pad(
|
pub async fn create_pad(
|
||||||
@@ -696,6 +810,7 @@ pub async fn pad_info(
|
|||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||||
Ok(Json(PadInfo {
|
Ok(Json(PadInfo {
|
||||||
slug: pad.slug,
|
slug: pad.slug,
|
||||||
title: pad.title,
|
title: pad.title,
|
||||||
@@ -711,9 +826,64 @@ pub async fn pad_info(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
|
global_color,
|
||||||
|
note_color,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn pad_editor_color(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"global_color": global_color, "note_color": note_color}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn note_editor_color(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let (global_color, note_color) = editor_colors(
|
||||||
|
&state,
|
||||||
|
&headers,
|
||||||
|
"note",
|
||||||
|
&format!("{}/{}", workspace_slug, note_slug),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"global_color": global_color, "note_color": note_color}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pad_editor_color(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<EditorColorRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_note_editor_color(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
Json(payload): Json<EditorColorRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
save_editor_color(
|
||||||
|
&state,
|
||||||
|
&headers,
|
||||||
|
"note",
|
||||||
|
&format!("{}/{}", workspace_slug, note_slug),
|
||||||
|
payload.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn publish_pad_page(
|
pub async fn publish_pad_page(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -730,8 +900,17 @@ pub async fn publish_pad_page(
|
|||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||||
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let token = db::publish_pad(&state.db, pad.id).await?;
|
let token = db::publish_pad(&state.db, pad.id).await?;
|
||||||
@@ -758,8 +937,17 @@ pub async fn publish_note_page(
|
|||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let token = db::publish_note(&state.db, note.id).await?;
|
let token = db::publish_note(&state.db, note.id).await?;
|
||||||
@@ -849,8 +1037,17 @@ pub async fn pad_restore(
|
|||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||||
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
|
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
|
||||||
@@ -892,9 +1089,7 @@ async fn authorized_pad(
|
|||||||
let pad = db::find_pad(&state.db, slug)
|
let pad = db::find_pad(&state.db, slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let token_level = combined_token_access_level(
|
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
|
||||||
state, "pad", slug, access_token, bearer,
|
|
||||||
).await?;
|
|
||||||
if pad.is_private != 0 && token_level == AccessLevel::None {
|
if pad.is_private != 0 && token_level == AccessLevel::None {
|
||||||
return Err(ApiError::forbidden("This note is private."));
|
return Err(ApiError::forbidden("This note is private."));
|
||||||
}
|
}
|
||||||
@@ -961,11 +1156,27 @@ pub async fn upload_pad_file(
|
|||||||
file = Some((filename, bytes.to_vec()));
|
file = Some((filename, bytes.to_vec()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers)).await?;
|
let pad = authorized_pad(
|
||||||
|
&state,
|
||||||
|
&slug,
|
||||||
|
password.as_deref(),
|
||||||
|
access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, password.as_deref())
|
let level = if db::verify_pad_password(&pad, password.as_deref())
|
||||||
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "pad", &slug, access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&slug,
|
||||||
|
access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||||
@@ -1119,8 +1330,17 @@ pub async fn upload_note_file(
|
|||||||
|
|
||||||
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "workspace", &workspace_slug, access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||||
@@ -1178,8 +1398,17 @@ pub async fn delete_note(
|
|||||||
|
|
||||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{ AccessLevel::Write } else {
|
{
|
||||||
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
combined_token_access_level(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
if note.protected {
|
if note.protected {
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ pub fn router(
|
|||||||
.route("/api/pads", post(api::create_pad))
|
.route("/api/pads", post(api::create_pad))
|
||||||
.route("/api/pads/{slug}", get(api::pad_info))
|
.route("/api/pads/{slug}", get(api::pad_info))
|
||||||
.route("/api/pads/{slug}/history", post(api::pad_history))
|
.route("/api/pads/{slug}/history", post(api::pad_history))
|
||||||
|
.route(
|
||||||
|
"/api/pads/{slug}/editor-color",
|
||||||
|
get(api::pad_editor_color).post(api::set_pad_editor_color),
|
||||||
|
)
|
||||||
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
||||||
.route("/api/pads/{slug}/restore", post(api::pad_restore))
|
.route("/api/pads/{slug}/restore", post(api::pad_restore))
|
||||||
.route(
|
.route(
|
||||||
@@ -133,6 +137,10 @@ pub fn router(
|
|||||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||||
get(api::note_info).delete(api::delete_note),
|
get(api::note_info).delete(api::delete_note),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
|
||||||
|
get(api::note_editor_color).post(api::set_note_editor_color),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
|
||||||
post(api::publish_note_page),
|
post(api::publish_note_page),
|
||||||
|
|||||||
+281
-68
@@ -77,15 +77,25 @@ pub struct ResetConfirmRequest {
|
|||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ProfileUpdateRequest {
|
pub struct ProfileUpdateRequest {
|
||||||
#[serde(default)] nickname: Option<String>,
|
#[serde(default)]
|
||||||
#[serde(default)] new_email: Option<String>,
|
nickname: Option<String>,
|
||||||
#[serde(default)] new_password: Option<String>,
|
#[serde(default)]
|
||||||
#[serde(default)] password: String,
|
new_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
new_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
editor_color: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DeleteAccountRequest { password: String }
|
pub struct DeleteAccountRequest {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AccountActionConfirmRequest { token: String }
|
pub struct AccountActionConfirmRequest {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ResourceActionRequest {
|
pub struct ResourceActionRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -163,6 +173,7 @@ pub struct SessionResponse {
|
|||||||
directory_display_name: Option<String>,
|
directory_display_name: Option<String>,
|
||||||
directory_organization: Option<String>,
|
directory_organization: Option<String>,
|
||||||
suggested_nickname: Option<String>,
|
suggested_nickname: Option<String>,
|
||||||
|
editor_color: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct IdentityResponse {
|
pub struct IdentityResponse {
|
||||||
@@ -367,18 +378,23 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn resend_confirmation(
|
pub async fn resend_confirmation(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(req): Json<ResendConfirmationRequest>,
|
Json(req): Json<ResendConfirmationRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
if !state.account_confirmation_required {
|
if !state.account_confirmation_required {
|
||||||
return Err(AuthError::bad_request("Account confirmation is not enabled."));
|
return Err(AuthError::bad_request(
|
||||||
|
"Account confirmation is not enabled.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
let smtp = state
|
||||||
|
.smtp
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
||||||
let email = validate_email(&req.email)?;
|
let email = validate_email(&req.email)?;
|
||||||
let user = find_user_by_email(&state, &email).await?
|
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| {
|
||||||
.ok_or_else(|| AuthError::bad_request("No unconfirmed account exists for this e-mail address."))?;
|
AuthError::bad_request("No unconfirmed account exists for this e-mail address.")
|
||||||
|
})?;
|
||||||
if user.confirmed_at.is_some() {
|
if user.confirmed_at.is_some() {
|
||||||
return Err(AuthError::bad_request("This account is already confirmed."));
|
return Err(AuthError::bad_request("This account is already confirmed."));
|
||||||
}
|
}
|
||||||
@@ -386,25 +402,47 @@ pub async fn resend_confirmation(
|
|||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
queries::AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
||||||
))
|
))
|
||||||
.bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
.bind(user.id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
if let Some(value) = last_created {
|
if let Some(value) = last_created {
|
||||||
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) {
|
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) {
|
||||||
let available = created.with_timezone(&Utc) + Duration::minutes(10);
|
let available = created.with_timezone(&Utc) + Duration::minutes(10);
|
||||||
if available > Utc::now() {
|
if available > Utc::now() {
|
||||||
let seconds = (available - Utc::now()).num_seconds().max(1);
|
let seconds = (available - Utc::now()).num_seconds().max(1);
|
||||||
return Err(AuthError::bad_request(&format!("A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60)));
|
return Err(AuthError::bad_request(&format!(
|
||||||
|
"A new confirmation e-mail can be sent in {} minute(s).",
|
||||||
|
(seconds + 59) / 60
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER))
|
sqlx::query(queries::get(
|
||||||
.bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
|
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
let token = random_confirmation_token();
|
let token = random_confirmation_token();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
sqlx::query(queries::get(
|
||||||
.bind(token_hash).bind(user.id).bind(expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
|
queries::AUTH_INSERT_CONFIRMATION_TOKEN,
|
||||||
|
))
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
send_registration_email(smtp, &user, Some(&token)).await?;
|
send_registration_email(smtp, &user, Some(&token)).await?;
|
||||||
Ok(Json(serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."})))
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn confirm_account(
|
pub async fn confirm_account(
|
||||||
@@ -504,7 +542,10 @@ async fn directory_profile_metadata(
|
|||||||
|
|
||||||
fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option<String> {
|
fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option<String> {
|
||||||
if let Some(display_name) = display_name {
|
if let Some(display_name) = display_name {
|
||||||
let words: Vec<&str> = display_name.split_whitespace().filter(|word| !word.is_empty()).collect();
|
let words: Vec<&str> = display_name
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.collect();
|
||||||
if words.len() >= 2 {
|
if words.len() >= 2 {
|
||||||
let first = words.first().copied().unwrap_or_default();
|
let first = words.first().copied().unwrap_or_default();
|
||||||
let last = words.last().copied().unwrap_or_default();
|
let last = words.last().copied().unwrap_or_default();
|
||||||
@@ -540,6 +581,14 @@ pub async fn me(
|
|||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
||||||
directory_profile_metadata(&state, &user).await?;
|
directory_profile_metadata(&state, &user).await?;
|
||||||
|
let editor_color: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_EDITOR_COLOR_BY_USER,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
Ok(Json(SessionResponse {
|
Ok(Json(SessionResponse {
|
||||||
token: token.into(),
|
token: token.into(),
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
@@ -549,6 +598,7 @@ pub async fn me(
|
|||||||
directory_display_name,
|
directory_display_name,
|
||||||
directory_organization,
|
directory_organization,
|
||||||
suggested_nickname,
|
suggested_nickname,
|
||||||
|
editor_color,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,14 +636,9 @@ pub async fn update_profile(
|
|||||||
if normalize(&nickname) != normalize(&user.nickname)
|
if normalize(&nickname) != normalize(&user.nickname)
|
||||||
&& find_user_by_nickname(&state, &nickname).await?.is_some()
|
&& find_user_by_nickname(&state, &nickname).await?.is_some()
|
||||||
{
|
{
|
||||||
return Err(AuthError::conflict(
|
return Err(AuthError::conflict("This nickname is already registered."));
|
||||||
"This nickname is already registered.",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_NICKNAME))
|
||||||
state.db.kind(),
|
|
||||||
queries::AUTH_UPDATE_NICKNAME,
|
|
||||||
))
|
|
||||||
.bind(&nickname)
|
.bind(&nickname)
|
||||||
.bind(normalize(&nickname))
|
.bind(normalize(&nickname))
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
@@ -605,13 +650,14 @@ pub async fn update_profile(
|
|||||||
|
|
||||||
let mut email_pending = false;
|
let mut email_pending = false;
|
||||||
if !directory_managed {
|
if !directory_managed {
|
||||||
if let Some(value) = req.new_password.as_deref().filter(|value| !value.is_empty()) {
|
if let Some(value) = req
|
||||||
|
.new_password
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
validate_password(value)?;
|
validate_password(value)?;
|
||||||
let hash = hash_password(value)?;
|
let hash = hash_password(value)?;
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
|
||||||
state.db.kind(),
|
|
||||||
queries::AUTH_UPDATE_PASSWORD,
|
|
||||||
))
|
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -632,18 +678,34 @@ pub async fn update_profile(
|
|||||||
"This e-mail address is already registered.",
|
"This e-mail address is already registered.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
let smtp = state
|
||||||
AuthError::service_unavailable("SMTP is not configured.")
|
.smtp
|
||||||
})?;
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
||||||
create_account_action(&state, &user, "email", Some(&email), smtp).await?;
|
create_account_action(&state, &user, "email", Some(&email), smtp).await?;
|
||||||
email_pending = true;
|
email_pending = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(value) = req.editor_color.as_deref() {
|
||||||
|
let color = validate_editor_color(value)?;
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_UPDATE_EDITOR_COLOR,
|
||||||
|
))
|
||||||
|
.bind(color)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"nickname": nickname,
|
"nickname": nickname,
|
||||||
|
"editor_color": req.editor_color.as_deref(),
|
||||||
"email_pending": email_pending,
|
"email_pending": email_pending,
|
||||||
"message": if email_pending {
|
"message": if email_pending {
|
||||||
"Profile updated. Confirm the new e-mail address using the link sent to it."
|
"Profile updated. Confirm the new e-mail address using the link sent to it."
|
||||||
@@ -654,60 +716,189 @@ pub async fn update_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn request_account_deletion(
|
pub async fn request_account_deletion(
|
||||||
State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<DeleteAccountRequest>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<DeleteAccountRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
if state.ldap.is_some() { return Err(AuthError::forbidden("LDAP accounts cannot be deleted here.")); }
|
if state.ldap.is_some() {
|
||||||
|
return Err(AuthError::forbidden(
|
||||||
|
"LDAP accounts cannot be deleted here.",
|
||||||
|
));
|
||||||
|
}
|
||||||
let user = require_user(&state, &headers).await?;
|
let user = require_user(&state, &headers).await?;
|
||||||
if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("The current password is incorrect.")); }
|
if !verify_password(&user.password_hash, &req.password) {
|
||||||
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
return Err(AuthError::unauthorized(
|
||||||
|
"The current password is incorrect.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let smtp = state
|
||||||
|
.smtp
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
||||||
create_account_action(&state, &user, "delete", None, smtp).await?;
|
create_account_action(&state, &user, "delete", None, smtp).await?;
|
||||||
Ok(Json(serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."})))
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn confirm_account_action(
|
pub async fn confirm_account_action(
|
||||||
State(state): State<SharedState>, Json(req): Json<AccountActionConfirmRequest>,
|
State(state): State<SharedState>,
|
||||||
|
Json(req): Json<AccountActionConfirmRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
let now = Utc::now(); let hash = hash_token(req.token.trim());
|
let now = Utc::now();
|
||||||
let row: Option<(i64,String,Option<String>,String,Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN))
|
let hash = hash_token(req.token.trim());
|
||||||
.bind(&hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
let row: Option<(i64, String, Option<String>, String, Option<String>)> = sqlx::query_as(
|
||||||
let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
|
queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN),
|
||||||
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc);
|
)
|
||||||
if used_at.is_some() || expires <= now { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); }
|
.bind(&hash)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
|
let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| {
|
||||||
|
AuthError::bad_request("The confirmation link is invalid or has expired.")
|
||||||
|
})?;
|
||||||
|
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at)
|
||||||
|
.map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?
|
||||||
|
.with_timezone(&Utc);
|
||||||
|
if used_at.is_some() || expires <= now {
|
||||||
|
return Err(AuthError::bad_request(
|
||||||
|
"The confirmation link is invalid or has expired.",
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONSUME_ACCOUNT_ACTION))
|
sqlx::query(queries::get(
|
||||||
.bind(now.to_rfc3339()).bind(&hash).execute(&mut *tx).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
|
queries::AUTH_CONSUME_ACCOUNT_ACTION,
|
||||||
|
))
|
||||||
|
.bind(now.to_rfc3339())
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
let message = if action == "email" {
|
let message = if action == "email" {
|
||||||
let email = payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?;
|
let email = payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?;
|
||||||
if find_user_by_email(&state,&email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
|
if find_user_by_email(&state, &email).await?.is_some() {
|
||||||
|
return Err(AuthError::conflict(
|
||||||
|
"This e-mail address is already registered.",
|
||||||
|
));
|
||||||
|
}
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL))
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL))
|
||||||
.bind(&email).bind(normalize(&email)).bind(now.to_rfc3339()).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
.bind(&email)
|
||||||
|
.bind(normalize(&email))
|
||||||
|
.bind(now.to_rfc3339())
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
"E-mail address changed."
|
"E-mail address changed."
|
||||||
} else if action == "delete" {
|
} else if action == "delete" {
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER))
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
"Account deleted."
|
"Account deleted."
|
||||||
} else { return Err(AuthError::bad_request("Unknown account action.")); };
|
} else {
|
||||||
|
return Err(AuthError::bad_request("Unknown account action."));
|
||||||
|
};
|
||||||
tx.commit().await.map_err(AuthError::database)?;
|
tx.commit().await.map_err(AuthError::database)?;
|
||||||
Ok(Json(serde_json::json!({"ok":true,"message":message})))
|
Ok(Json(serde_json::json!({"ok":true,"message":message})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_account_action(state:&SharedState,user:&User,action:&str,payload:Option<&str>,smtp:&SmtpConfig)->Result<(),AuthError>{
|
async fn create_account_action(
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_ACCOUNT_ACTIONS))
|
state: &SharedState,
|
||||||
.bind(user.id).bind(action).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
user: &User,
|
||||||
let token=random_token(); let expires=(Utc::now()+Duration::hours(1)).to_rfc3339();
|
action: &str,
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_ACCOUNT_ACTION))
|
payload: Option<&str>,
|
||||||
.bind(hash_token(&token)).bind(user.id).bind(action).bind(payload).bind(expires).bind(Utc::now().to_rfc3339()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
smtp: &SmtpConfig,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_DELETE_ACCOUNT_ACTIONS,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(action)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
|
let token = random_token();
|
||||||
|
let expires = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_INSERT_ACCOUNT_ACTION,
|
||||||
|
))
|
||||||
|
.bind(hash_token(&token))
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(action)
|
||||||
|
.bind(payload)
|
||||||
|
.bind(expires)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
send_account_action(smtp, user, action, payload, &token).await
|
send_account_action(smtp, user, action, payload, &token).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_account_action(smtp:&SmtpConfig,user:&User,action:&str,payload:Option<&str>,token:&str)->Result<(),AuthError>{
|
async fn send_account_action(
|
||||||
let site=smtp.public_url.trim_end_matches('/'); let url=format!("{site}/?account_action_token={token}");
|
smtp: &SmtpConfig,
|
||||||
let sender=smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?;
|
user: &User,
|
||||||
let target=if action=="email" { payload.unwrap_or(&user.email) } else { &user.email };
|
action: &str,
|
||||||
let recipient=target.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?;
|
payload: Option<&str>,
|
||||||
let (subject,title,copy)=if action=="email" {("Confirm your new RustPad e-mail","Confirm e-mail change","Confirm the new e-mail address within one hour.")} else {("Confirm RustPad account deletion","Confirm account deletion","Confirm permanent account deletion within one hour.")};
|
token: &str,
|
||||||
let text=format!("Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.",user.nickname,copy,url);
|
) -> Result<(), AuthError> {
|
||||||
let html=format!(r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,title,user.nickname,copy,url,url,url);
|
let site = smtp.public_url.trim_end_matches('/');
|
||||||
let message=Message::builder().from(sender).to(recipient).subject(subject).multipart(MultiPart::alternative().singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text)).singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html))).map_err(|_|AuthError::internal("Failed to build account confirmation e-mail."))?;
|
let url = format!("{site}/?account_action_token={token}");
|
||||||
|
let sender = smtp
|
||||||
|
.from
|
||||||
|
.parse::<Mailbox>()
|
||||||
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
||||||
|
let target = if action == "email" {
|
||||||
|
payload.unwrap_or(&user.email)
|
||||||
|
} else {
|
||||||
|
&user.email
|
||||||
|
};
|
||||||
|
let recipient = target
|
||||||
|
.parse::<Mailbox>()
|
||||||
|
.map_err(|_| AuthError::internal("Invalid recipient address."))?;
|
||||||
|
let (subject, title, copy) = if action == "email" {
|
||||||
|
(
|
||||||
|
"Confirm your new RustPad e-mail",
|
||||||
|
"Confirm e-mail change",
|
||||||
|
"Confirm the new e-mail address within one hour.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"Confirm RustPad account deletion",
|
||||||
|
"Confirm account deletion",
|
||||||
|
"Confirm permanent account deletion within one hour.",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let text = format!(
|
||||||
|
"Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.",
|
||||||
|
user.nickname, copy, url
|
||||||
|
);
|
||||||
|
let html = format!(
|
||||||
|
r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,
|
||||||
|
title, user.nickname, copy, url, url, url
|
||||||
|
);
|
||||||
|
let message = Message::builder()
|
||||||
|
.from(sender)
|
||||||
|
.to(recipient)
|
||||||
|
.subject(subject)
|
||||||
|
.multipart(
|
||||||
|
MultiPart::alternative()
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(text),
|
||||||
|
)
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_HTML)
|
||||||
|
.body(html),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|_| AuthError::internal("Failed to build account confirmation e-mail."))?;
|
||||||
send_message(smtp, message, "account action e-mail").await
|
send_message(smtp, message, "account action e-mail").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1476,7 +1667,8 @@ pub async fn confirm_reset(
|
|||||||
pub async fn user_from_token(state: &SharedState, token: &str) -> Result<Option<User>, AuthError> {
|
pub async fn user_from_token(state: &SharedState, token: &str) -> Result<Option<User>, AuthError> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let now_rfc3339 = now.to_rfc3339();
|
let now_rfc3339 = now.to_rfc3339();
|
||||||
let user = sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION))
|
let user =
|
||||||
|
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION))
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.bind(&now_rfc3339)
|
.bind(&now_rfc3339)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(state.db.pool())
|
||||||
@@ -1553,6 +1745,14 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
|||||||
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
||||||
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
||||||
directory_profile_metadata(state, user).await?;
|
directory_profile_metadata(state, user).await?;
|
||||||
|
let editor_color: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_EDITOR_COLOR_BY_USER,
|
||||||
|
))
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
Ok(SessionResponse {
|
Ok(SessionResponse {
|
||||||
token,
|
token,
|
||||||
nickname: user.nickname.clone(),
|
nickname: user.nickname.clone(),
|
||||||
@@ -1562,6 +1762,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
|||||||
directory_display_name,
|
directory_display_name,
|
||||||
directory_organization,
|
directory_organization,
|
||||||
suggested_nickname,
|
suggested_nickname,
|
||||||
|
editor_color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn find_user_by_nickname(
|
async fn find_user_by_nickname(
|
||||||
@@ -1600,6 +1801,18 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<U
|
|||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)
|
.map_err(AuthError::database)
|
||||||
}
|
}
|
||||||
|
fn validate_editor_color(value: &str) -> Result<String, AuthError> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.len() == 7
|
||||||
|
&& value.starts_with('#')
|
||||||
|
&& value[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
Ok(value.to_ascii_lowercase())
|
||||||
|
} else {
|
||||||
|
Err(AuthError::bad_request("Invalid editor color."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_nickname(v: &str) -> Result<String, AuthError> {
|
fn validate_nickname(v: &str) -> Result<String, AuthError> {
|
||||||
let v = v.trim();
|
let v = v.trim();
|
||||||
if v.is_empty() || v.chars().count() > MAX_NICKNAME {
|
if v.is_empty() || v.chars().count() > MAX_NICKNAME {
|
||||||
|
|||||||
+20
-7
@@ -17,22 +17,35 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
|
|||||||
|
|
||||||
// Authentication queries.
|
// Authentication queries.
|
||||||
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: &str = "SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1";
|
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: &str = "SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1";
|
||||||
pub const AUTH_UPDATE_NICKNAME: &str = "UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?";
|
pub const AUTH_UPDATE_NICKNAME: &str =
|
||||||
|
"UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?";
|
||||||
|
pub const AUTH_UPDATE_EDITOR_COLOR: &str =
|
||||||
|
"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?";
|
||||||
|
pub const AUTH_EDITOR_COLOR_BY_USER: &str = "SELECT editor_color FROM users WHERE id = ?";
|
||||||
|
pub const RESOURCE_COLOR_BY_USER: &str = "SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?";
|
||||||
|
pub const RESOURCE_COLOR_DELETE: &str = "DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?";
|
||||||
|
pub const RESOURCE_COLOR_INSERT: &str = "INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)";
|
||||||
pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: &str = "SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?";
|
pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: &str = "SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?";
|
||||||
pub const AUTH_CONSUME_ACCOUNT_ACTION: &str = "UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL";
|
pub const AUTH_CONSUME_ACCOUNT_ACTION: &str =
|
||||||
pub const AUTH_UPDATE_EMAIL: &str = "UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?";
|
"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL";
|
||||||
pub const AUTH_DELETE_ACCOUNT_ACTIONS: &str = "DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?";
|
pub const AUTH_UPDATE_EMAIL: &str =
|
||||||
|
"UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?";
|
||||||
|
pub const AUTH_DELETE_ACCOUNT_ACTIONS: &str =
|
||||||
|
"DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?";
|
||||||
pub const AUTH_INSERT_ACCOUNT_ACTION: &str = "INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)";
|
pub const AUTH_INSERT_ACCOUNT_ACTION: &str = "INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
pub const AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS: &str = "DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?";
|
pub const AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS: &str =
|
||||||
|
"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?";
|
||||||
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, directory_display_name = ?, updated_at = ? WHERE id = ?";
|
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, directory_display_name = ?, updated_at = ? WHERE id = ?";
|
||||||
pub const AUTH_DIRECTORY_PROFILE_BY_USER: &str = "SELECT auth_provider, directory_display_name FROM users WHERE id = ?";
|
pub const AUTH_DIRECTORY_PROFILE_BY_USER: &str =
|
||||||
|
"SELECT auth_provider, directory_display_name FROM users WHERE id = ?";
|
||||||
pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?";
|
pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?";
|
||||||
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
||||||
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
||||||
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
||||||
pub const AUTH_REFRESH_SESSION: &str = "UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?";
|
pub const AUTH_REFRESH_SESSION: &str =
|
||||||
|
"UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?";
|
||||||
pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str =
|
pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str =
|
||||||
"DELETE FROM account_confirmation_tokens WHERE user_id = ?";
|
"DELETE FROM account_confirmation_tokens WHERE user_id = ?";
|
||||||
pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str =
|
pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str =
|
||||||
|
|||||||
+304
-57
@@ -263,7 +263,7 @@ textarea:focus {
|
|||||||
.inline-button {
|
.inline-button {
|
||||||
width: auto;
|
width: auto;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding: 0 16px;
|
padding: 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -445,6 +445,11 @@ textarea:focus {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace > .editor-column,
|
||||||
|
.workspace > .preview-column {
|
||||||
|
transition: opacity .18s ease, transform .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-column,
|
.editor-column,
|
||||||
.preview-column {
|
.preview-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -823,43 +828,6 @@ dialog::backdrop {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
overflow-x: auto;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-fill {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace.view-split {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace.view-split .preview-column {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-switch button[data-view="split"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-column {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea,
|
|
||||||
.preview {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-footer {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -961,10 +929,6 @@ dialog::backdrop {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-button {
|
|
||||||
width: auto;
|
|
||||||
padding-inline: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card {
|
.error-card {
|
||||||
width: min(560px, 100%);
|
width: min(560px, 100%);
|
||||||
@@ -1117,6 +1081,7 @@ dialog::backdrop {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip {
|
.user-chip {
|
||||||
@@ -3623,17 +3588,6 @@ dialog::backdrop {
|
|||||||
padding: 7px 11px;
|
padding: 7px 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.share-dialog {
|
.share-dialog {
|
||||||
@@ -4078,7 +4032,9 @@ dialog::backdrop {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-owner-badge[hidden] { display: none; }
|
.document-owner-badge[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Per-character authorship overlay. The textarea remains the editable surface. */
|
/* Per-character authorship overlay. The textarea remains the editable surface. */
|
||||||
.authorship-layer {
|
.authorship-layer {
|
||||||
@@ -4109,11 +4065,15 @@ dialog::backdrop {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-editor-line-numbers .authorship-layer { left: 0; }
|
.hide-editor-line-numbers .authorship-layer {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.authorship-layer { left: 42px; }
|
.authorship-layer { left: 42px; }
|
||||||
.hide-editor-line-numbers .authorship-layer { left: 0; }
|
.hide-editor-line-numbers .authorship-layer {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multiple authors can contribute to one line. */
|
/* Multiple authors can contribute to one line. */
|
||||||
@@ -4210,7 +4170,10 @@ dialog::backdrop {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-access{font-weight:600;color:var(--muted,#9ca3af)}
|
.footer-access {
|
||||||
|
color: var(--muted, #9ca3af);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-suggestion {
|
.profile-suggestion {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -4222,3 +4185,287 @@ dialog::backdrop {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: .78;
|
opacity: .78;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user-control {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-color-field input[type="color"] {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive note editor -------------------------------------------------- */
|
||||||
|
@media (max-width: 1499px) {
|
||||||
|
.pad-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .app-header {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .app-header__main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .document-heading {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-user-control {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-navigation {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-menu-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-menu-toggle span {
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
transition: transform .18s ease, opacity .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
width: min(320px, calc(100vw - 20px));
|
||||||
|
max-height: min(70dvh, 520px);
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0d1015;
|
||||||
|
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-actions.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-actions > * {
|
||||||
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-actions .secondary-button,
|
||||||
|
.pad-page .header-actions .user-chip {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .header-actions .public-task-toggle {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-layout {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .toolbar-group {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-right: 0;
|
||||||
|
border-right: 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-controls,
|
||||||
|
.pad-page .toolbar-action,
|
||||||
|
.pad-page .line-toggle,
|
||||||
|
.pad-page .toolbar-fill,
|
||||||
|
.pad-page #mode-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .view-switch {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
align-self: center;
|
||||||
|
background: #0d1015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .view-switch button[data-view="split"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .workspace,
|
||||||
|
.pad-page .workspace.view-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .workspace.view-edit .editor-column,
|
||||||
|
.pad-page .workspace.view-preview .preview-column {
|
||||||
|
display: grid;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .workspace.view-edit .preview-column,
|
||||||
|
.pad-page .workspace.view-preview .editor-column {
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .preview-column {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page textarea,
|
||||||
|
.pad-page .preview,
|
||||||
|
.pad-page .authorship-layer {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-footer {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .footer-left,
|
||||||
|
.pad-page .footer-right {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.pad-page .app-header {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .brand {
|
||||||
|
max-width: 40vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .document-heading h1 {
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .user-chip__name {
|
||||||
|
max-width: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-toolbar {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-toolbar button,
|
||||||
|
.pad-page .editor-toolbar select {
|
||||||
|
min-height: 32px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page textarea,
|
||||||
|
.pad-page .preview,
|
||||||
|
.pad-page .authorship-layer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1500px) {
|
||||||
|
.pad-page .workspace.view-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-note author color override */
|
||||||
|
.use-global-color {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-global-color:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
<label data-directory-profile-field>Organization<input id="profile-directory-organization" readonly></label>
|
<label data-directory-profile-field>Organization<input id="profile-directory-organization" readonly></label>
|
||||||
<p id="profile-nickname-suggestion" data-directory-profile-field class="profile-suggestion"></p>
|
<p id="profile-nickname-suggestion" data-directory-profile-field class="profile-suggestion"></p>
|
||||||
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
|
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
|
||||||
|
<label class="profile-color-field">Editor color<input id="profile-color" type="color" aria-label="Choose your editor color"></label>
|
||||||
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
|
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
|
||||||
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320" placeholder="Leave empty to keep current"></label>
|
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320" placeholder="Leave empty to keep current"></label>
|
||||||
<label data-local-profile-field>New password<input id="profile-new-password" type="password" minlength="8" maxlength="128" placeholder="Leave empty to keep current"></label>
|
<label data-local-profile-field>New password<input id="profile-new-password" type="password" minlength="8" maxlength="128" placeholder="Leave empty to keep current"></label>
|
||||||
|
|||||||
+4
-1
@@ -105,6 +105,7 @@ const resourcesDialog = document.querySelector("#resources-dialog");
|
|||||||
const resourcesList = document.querySelector("#resources-list");
|
const resourcesList = document.querySelector("#resources-list");
|
||||||
const resourcesError = document.querySelector("#resources-error");
|
const resourcesError = document.querySelector("#resources-error");
|
||||||
const profileDialog = document.querySelector("#profile-dialog");
|
const profileDialog = document.querySelector("#profile-dialog");
|
||||||
|
|
||||||
const profileForm = document.querySelector("#profile-form");
|
const profileForm = document.querySelector("#profile-form");
|
||||||
let currentSession = null;
|
let currentSession = null;
|
||||||
|
|
||||||
@@ -269,6 +270,8 @@ if (identityDialog) {
|
|||||||
|
|
||||||
document.querySelector("#footer-profile")?.addEventListener("click", () => {
|
document.querySelector("#footer-profile")?.addEventListener("click", () => {
|
||||||
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
|
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
|
||||||
|
const profileColor = document.querySelector("#profile-color");
|
||||||
|
profileColor.value = currentSession?.editor_color || "#7c6cff";
|
||||||
document.querySelector("#profile-current-email").value = currentSession?.email || "";
|
document.querySelector("#profile-current-email").value = currentSession?.email || "";
|
||||||
document.querySelector("#profile-email").value = "";
|
document.querySelector("#profile-email").value = "";
|
||||||
document.querySelector("#profile-new-password").value = "";
|
document.querySelector("#profile-new-password").value = "";
|
||||||
@@ -294,7 +297,7 @@ if (identityDialog) {
|
|||||||
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
|
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
|
||||||
profileForm?.addEventListener("submit", async event => {
|
profileForm?.addEventListener("submit", async event => {
|
||||||
event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error");
|
event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error");
|
||||||
try { const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
|
try { const selectedColor=document.querySelector("#profile-color").value; const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
|
||||||
});
|
});
|
||||||
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
|
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
|
||||||
const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value;
|
const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function createPadAdapter() {
|
|||||||
addressSelector: "#pad-url",
|
addressSelector: "#pad-url",
|
||||||
title: info => `${info.title} · RustPad`,
|
title: info => `${info.title} · RustPad`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
loadInfo: headers => api(base, { headers }),
|
||||||
|
loadColor: headers => api(`${base}/editor-color`, { headers }),
|
||||||
|
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
|
||||||
fileEndpoints: {
|
fileEndpoints: {
|
||||||
list: `${base}/files`,
|
list: `${base}/files`,
|
||||||
upload: `${base}/files`,
|
upload: `${base}/files`,
|
||||||
@@ -53,6 +55,8 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
addressSelector: "#note-url",
|
addressSelector: "#note-url",
|
||||||
title: info => `${info.title} · ${info.workspace_title}`,
|
title: info => `${info.title} · ${info.workspace_title}`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
loadInfo: headers => api(base, { headers }),
|
||||||
|
loadColor: headers => api(`${base}/editor-color`, { headers }),
|
||||||
|
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
|
||||||
fileEndpoints: {
|
fileEndpoints: {
|
||||||
list: `${base}/files`,
|
list: `${base}/files`,
|
||||||
upload: `${base}/files`,
|
upload: `${base}/files`,
|
||||||
|
|||||||
+98
-16
@@ -16,25 +16,42 @@ export function startNoteEditor(adapter) {
|
|||||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
|
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
|
||||||
let unreadChat = 0;
|
let unreadChat = 0;
|
||||||
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
|
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
|
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
|
||||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
|
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
|
||||||
|
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
||||||
|
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
||||||
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
||||||
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
|
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
|
||||||
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
|
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
|
||||||
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
|
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
|
||||||
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
|
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
|
||||||
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
|
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
|
||||||
function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
|
|
||||||
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
|
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
|
||||||
function ownerName(owner) { return ownerParts(owner).name; }
|
function ownerName(owner) { return ownerParts(owner).name; }
|
||||||
function colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
|
function colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
|
||||||
function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
|
const guestColorKey = `rustpad:guest-color:${adapter.access.kind}:${adapter.access.key}`;
|
||||||
|
function readGuestColor() { return sessionStorage.getItem(guestColorKey) || ""; }
|
||||||
|
function writeGuestColor(color) { if (color) sessionStorage.setItem(guestColorKey, color); else sessionStorage.removeItem(guestColorKey); }
|
||||||
|
function globalUserColor() { return globalColor || ""; }
|
||||||
|
function noteUserColor() { return noteColor || ""; }
|
||||||
|
function currentUserColor() { return noteUserColor() || globalUserColor(); }
|
||||||
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
|
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
|
||||||
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
|
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; }
|
||||||
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
|
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
|
||||||
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
||||||
async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); return info; }
|
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
||||||
|
async function loadNoteInfo() {
|
||||||
|
info = await adapter.loadInfo(sessionHeaders());
|
||||||
|
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
||||||
|
if (getAuthToken()) {
|
||||||
|
const colors = await adapter.loadColor(accountHeaders());
|
||||||
|
globalColor = colors.global_color || ""; noteColor = colors.note_color || "";
|
||||||
|
} else {
|
||||||
|
noteColor = readGuestColor();
|
||||||
|
}
|
||||||
|
updateCurrentUser(); return info;
|
||||||
|
}
|
||||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } }
|
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } }
|
||||||
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
||||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||||
@@ -169,7 +186,28 @@ export function startNoteEditor(adapter) {
|
|||||||
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
|
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
|
||||||
}
|
}
|
||||||
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
|
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
|
||||||
function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
|
function activeView() {
|
||||||
|
return compactLayoutQuery.matches ? compactView : uiState.view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUi({ write = false, replace = false } = {}) {
|
||||||
|
const view = activeView();
|
||||||
|
editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`;
|
||||||
|
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
||||||
|
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
||||||
|
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
|
||||||
|
document.querySelectorAll("[data-view]").forEach(button => {
|
||||||
|
const active = button.dataset.view === view;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
button.setAttribute("aria-pressed", String(active));
|
||||||
|
});
|
||||||
|
const markdown = uiState.mode === "markdown";
|
||||||
|
modeToggle.classList.toggle("active", markdown);
|
||||||
|
modeToggle.textContent = markdown ? "Markdown" : "Text";
|
||||||
|
render();
|
||||||
|
if (write) writeEditorState(uiState, { replace });
|
||||||
|
updateAddressLabel();
|
||||||
|
}
|
||||||
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
|
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
|
||||||
|
|
||||||
const { loadFiles } = bindNoteFiles({
|
const { loadFiles } = bindNoteFiles({
|
||||||
@@ -204,7 +242,40 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
|
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
|
||||||
|
if (compactLayoutQuery.matches) {
|
||||||
|
compactView = button.dataset.view === "preview" ? "preview" : "edit";
|
||||||
|
applyUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uiState = { ...uiState, view: button.dataset.view };
|
||||||
|
applyUi({ write: true });
|
||||||
|
}));
|
||||||
|
compactLayoutQuery.addEventListener("change", () => applyUi());
|
||||||
|
const headerMenuToggle = document.querySelector("#header-menu-toggle");
|
||||||
|
const headerActions = document.querySelector("#header-actions");
|
||||||
|
const setHeaderMenuOpen = open => {
|
||||||
|
headerActions.classList.toggle("is-open", open);
|
||||||
|
headerMenuToggle.setAttribute("aria-expanded", String(open));
|
||||||
|
headerMenuToggle.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu");
|
||||||
|
};
|
||||||
|
headerMenuToggle.addEventListener("click", event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
|
||||||
|
});
|
||||||
|
headerActions.addEventListener("click", event => {
|
||||||
|
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
|
||||||
|
});
|
||||||
|
document.addEventListener("click", event => {
|
||||||
|
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") setHeaderMenuOpen(false);
|
||||||
|
});
|
||||||
|
compactLayoutQuery.addEventListener("change", event => {
|
||||||
|
if (!event.matches) setHeaderMenuOpen(false);
|
||||||
|
});
|
||||||
|
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
|
||||||
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
|
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
|
||||||
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
|
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
|
||||||
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } });
|
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } });
|
||||||
@@ -212,26 +283,37 @@ export function startNoteEditor(adapter) {
|
|||||||
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
|
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
|
||||||
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
|
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
|
||||||
currentUser.addEventListener("click", () => userColorPicker.click());
|
currentUser.addEventListener("click", () => userColorPicker.click());
|
||||||
userColorPicker.addEventListener("input", () => {
|
userColorPicker.addEventListener("change", async () => {
|
||||||
localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
|
noteColor = userColorPicker.value;
|
||||||
|
if (getAuthToken()) {
|
||||||
|
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
|
||||||
|
} else {
|
||||||
|
writeGuestColor(noteColor);
|
||||||
|
toast("Color saved for this tab");
|
||||||
|
}
|
||||||
const replacement = currentOwner();
|
const replacement = currentOwner();
|
||||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||||
updateCurrentUser(); render();
|
updateCurrentUser(); render();
|
||||||
socket?.setColor(userColorPicker.value);
|
socket?.setColor(userColorPicker.value);
|
||||||
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||||
});
|
});
|
||||||
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
|
useGlobalColorButton.addEventListener("click", async () => {
|
||||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
if (getAuthToken()) {
|
||||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
|
||||||
|
}
|
||||||
window.addEventListener("storage", event => {
|
noteColor = "";
|
||||||
if (event.key !== storedColorKey(nickname)) return;
|
if (!getAuthToken()) writeGuestColor("");
|
||||||
const replacement = currentOwner();
|
const replacement = currentOwner();
|
||||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||||
updateCurrentUser(); render();
|
updateCurrentUser(); render();
|
||||||
socket?.setColor(currentUserColor() || null);
|
socket?.setColor(currentUserColor() || null);
|
||||||
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||||
|
toast("Global profile color restored");
|
||||||
});
|
});
|
||||||
|
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
|
||||||
|
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||||
|
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||||
|
|
||||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
||||||
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
||||||
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
||||||
|
|||||||
+9
-2
@@ -20,16 +20,23 @@
|
|||||||
<p id="note-url" class="document-url"></p>
|
<p id="note-url" class="document-url"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip"
|
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip"
|
||||||
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
|
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
|
||||||
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
|
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
|
||||||
type="color" aria-label="Choose your color"></span><button id="copy-link"
|
type="color" aria-label="Override color for this note"><button id="use-global-color" class="use-global-color" type="button" title="Use global profile color" aria-label="Use global profile color" hidden>↺</button></span>
|
||||||
|
<div class="header-navigation">
|
||||||
|
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
|
||||||
|
aria-controls="header-actions" aria-label="Open navigation menu">
|
||||||
|
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div id="header-actions" class="header-actions"><button id="copy-link"
|
||||||
class="secondary-button">Copy link</button><button id="publish-page"
|
class="secondary-button">Copy link</button><button id="publish-page"
|
||||||
class="secondary-button">Page</button><label class="public-task-toggle"
|
class="secondary-button">Page</button><label class="public-task-toggle"
|
||||||
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
|
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
|
||||||
type="checkbox"> Editable tasks on Page</label><button id="files-button"
|
type="checkbox"> Editable tasks on Page</label><button id="files-button"
|
||||||
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
|
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
|
||||||
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
|
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="editor-layout">
|
<main class="editor-layout">
|
||||||
<section class="editor-panel">
|
<section class="editor-panel">
|
||||||
|
|||||||
+9
-2
@@ -19,16 +19,23 @@
|
|||||||
<p id="pad-url" class="document-url"></p>
|
<p id="pad-url" class="document-url"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip"
|
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip"
|
||||||
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
|
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
|
||||||
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
|
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
|
||||||
type="color" aria-label="Choose your color"></span><button id="copy-link"
|
type="color" aria-label="Override color for this note"><button id="use-global-color" class="use-global-color" type="button" title="Use global profile color" aria-label="Use global profile color" hidden>↺</button></span>
|
||||||
|
<div class="header-navigation">
|
||||||
|
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
|
||||||
|
aria-controls="header-actions" aria-label="Open navigation menu">
|
||||||
|
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div id="header-actions" class="header-actions"><button id="copy-link"
|
||||||
class="secondary-button">Copy link</button><button id="publish-page"
|
class="secondary-button">Copy link</button><button id="publish-page"
|
||||||
class="secondary-button">Page</button><label class="public-task-toggle"
|
class="secondary-button">Page</button><label class="public-task-toggle"
|
||||||
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
|
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
|
||||||
type="checkbox"> Editable tasks on Page</label><button id="files-button"
|
type="checkbox"> Editable tasks on Page</label><button id="files-button"
|
||||||
class="secondary-button">Files</button><button id="history-button"
|
class="secondary-button">Files</button><button id="history-button"
|
||||||
class="secondary-button">History</button></div>
|
class="secondary-button">History</button></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="editor-layout">
|
<main class="editor-layout">
|
||||||
<section class="editor-panel">
|
<section class="editor-panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user