fixes
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.1.20"
|
version = "0.1.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.1.20"
|
version = "0.1.22"
|
||||||
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 @@
|
|||||||
|
ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE published_pages ADD COLUMN unprotected INTEGER NOT NULL DEFAULT 0;
|
||||||
+6
-2
@@ -81,6 +81,8 @@ pub struct PublishRequest {
|
|||||||
access_token: Option<String>,
|
access_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
allow_task_updates: bool,
|
allow_task_updates: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
unprotect_page: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -150,6 +152,7 @@ pub struct NoteInfo {
|
|||||||
protected: bool,
|
protected: bool,
|
||||||
note_protected: bool,
|
note_protected: bool,
|
||||||
allow_public_task_updates: bool,
|
allow_public_task_updates: bool,
|
||||||
|
public_page_unprotected: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
@@ -445,6 +448,7 @@ pub async fn note_info(
|
|||||||
protected: workspace.password_hash.is_some(),
|
protected: workspace.password_hash.is_some(),
|
||||||
note_protected: note.protected,
|
note_protected: note.protected,
|
||||||
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
||||||
|
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
can_delete_files: {
|
can_delete_files: {
|
||||||
@@ -651,7 +655,7 @@ async fn ensure_private_resource_access(
|
|||||||
if verify_resource_access_token(state, kind, slug, token).await? {
|
if verify_resource_access_token(state, kind, slug, token).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(ApiError::forbidden("This resource is private."))
|
Err(ApiError::not_found_workspace())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn authorized_workspace(
|
pub async fn authorized_workspace(
|
||||||
@@ -667,7 +671,7 @@ pub async fn authorized_workspace(
|
|||||||
let token_level =
|
let token_level =
|
||||||
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
|
combined_token_access_level(state, "workspace", slug, access_token, bearer).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::not_found_workspace());
|
||||||
}
|
}
|
||||||
if workspace.password_hash.is_some()
|
if workspace.password_hash.is_some()
|
||||||
&& !db::verify_workspace_password(&workspace, password)
|
&& !db::verify_workspace_password(&workspace, password)
|
||||||
|
|||||||
+47
-2
@@ -19,6 +19,7 @@ pub struct PadInfo {
|
|||||||
title: String,
|
title: String,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
allow_public_task_updates: bool,
|
allow_public_task_updates: bool,
|
||||||
|
public_page_unprotected: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
@@ -82,6 +83,7 @@ pub async fn pad_info(
|
|||||||
title: pad.title,
|
title: pad.title,
|
||||||
protected: pad.password_hash.is_some(),
|
protected: pad.password_hash.is_some(),
|
||||||
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
||||||
|
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
|
||||||
created_at: db::normalize_timestamp(&pad.created_at),
|
created_at: db::normalize_timestamp(&pad.created_at),
|
||||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||||
can_delete_files: crate::auth::is_resource_owner(
|
can_delete_files: crate::auth::is_resource_owner(
|
||||||
@@ -181,6 +183,7 @@ pub async fn publish_pad_page(
|
|||||||
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?;
|
||||||
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
|
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
|
||||||
|
db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?;
|
||||||
Ok(Json(PublishResponse {
|
Ok(Json(PublishResponse {
|
||||||
url: format!("/s/{token}"),
|
url: format!("/s/{token}"),
|
||||||
}))
|
}))
|
||||||
@@ -218,18 +221,58 @@ pub async fn publish_note_page(
|
|||||||
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?;
|
||||||
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
|
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
|
||||||
|
db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?;
|
||||||
Ok(Json(PublishResponse {
|
Ok(Json(PublishResponse {
|
||||||
url: format!("/s/{token}"),
|
url: format!("/s/{token}"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn page_password(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers.get("x-rustpad-page-password").and_then(|value| value.to_str().ok()).map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_public_page_access(state: &SharedState, headers: &HeaderMap, page: &db::PublishedPage) -> Result<(), ApiError> {
|
||||||
|
let password = page_password(headers);
|
||||||
|
let bearer = bearer_token(headers);
|
||||||
|
if let Some(pad_id) = page.pad_id {
|
||||||
|
if db::pad_public_page_unprotected(&state.db, pad_id).await? { return Ok(()); }
|
||||||
|
let sql = match state.db.kind() {
|
||||||
|
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
||||||
|
_ => "SELECT slug FROM pads WHERE id = ?",
|
||||||
|
};
|
||||||
|
let slug: Option<String> = sqlx::query_scalar(sql).bind(pad_id).fetch_optional(state.db.pool()).await?;
|
||||||
|
let Some(slug) = slug else { return Err(ApiError::not_found_note()); };
|
||||||
|
let pad = db::find_pad(&state.db, &slug).await?.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
if db::verify_pad_password(&pad, password) { return Ok(()); }
|
||||||
|
if verify_resource_access_token(state, "pad", &slug, bearer).await? { return Ok(()); }
|
||||||
|
return if pad.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) };
|
||||||
|
}
|
||||||
|
if let Some(note_id) = page.note_id {
|
||||||
|
if db::note_public_page_unprotected(&state.db, note_id).await? { return Ok(()); }
|
||||||
|
let sql = match state.db.kind() {
|
||||||
|
crate::database::DatabaseKind::Postgres => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1",
|
||||||
|
_ => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?",
|
||||||
|
};
|
||||||
|
let slug: Option<String> = sqlx::query_scalar(sql).bind(note_id).fetch_optional(state.db.pool()).await?;
|
||||||
|
let Some(slug) = slug else { return Err(ApiError::not_found_note()); };
|
||||||
|
let workspace = db::find_workspace(&state.db, &slug).await?.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
|
if db::verify_workspace_password(&workspace, password) { return Ok(()); }
|
||||||
|
if verify_resource_access_token(state, "workspace", &slug, bearer).await? { return Ok(()); }
|
||||||
|
return if workspace.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) };
|
||||||
|
}
|
||||||
|
Err(ApiError::not_found_note())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn public_page(
|
pub async fn public_page(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
) -> Result<Json<PublicPageResponse>, ApiError> {
|
) -> Result<Json<PublicPageResponse>, ApiError> {
|
||||||
let page = db::find_published_page(&state.db, &token)
|
let page = db::find_published_page(&state.db, &token)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
ensure_public_page_access(&state, &headers, &page).await?;
|
||||||
Ok(Json(PublicPageResponse {
|
Ok(Json(PublicPageResponse {
|
||||||
title: page.title,
|
title: page.title,
|
||||||
content: page.content,
|
content: page.content,
|
||||||
@@ -240,12 +283,14 @@ pub async fn public_page(
|
|||||||
|
|
||||||
pub async fn update_public_task(
|
pub async fn update_public_task(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
Json(payload): Json<PublicTaskUpdateRequest>,
|
Json(payload): Json<PublicTaskUpdateRequest>,
|
||||||
) -> Result<Json<PublicPageResponse>, ApiError> {
|
) -> Result<Json<PublicPageResponse>, ApiError> {
|
||||||
let current = db::find_published_page(&state.db, &token)
|
let current = db::find_published_page(&state.db, &token)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
ensure_public_page_access(&state, &headers, ¤t).await?;
|
||||||
if !current.allow_task_updates {
|
if !current.allow_task_updates {
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Task updates are disabled for this page",
|
"Task updates are disabled for this page",
|
||||||
@@ -357,13 +402,13 @@ pub(super) async fn authorized_pad(
|
|||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
|
let token_level = combined_token_access_level(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::not_found_note());
|
||||||
}
|
}
|
||||||
if pad.password_hash.is_some()
|
if pad.password_hash.is_some()
|
||||||
&& !db::verify_pad_password(&pad, password)
|
&& !db::verify_pad_password(&pad, password)
|
||||||
&& token_level == AccessLevel::None
|
&& token_level == AccessLevel::None
|
||||||
{
|
{
|
||||||
return Err(ApiError::unauthorized());
|
return Err(ApiError::forbidden("Password required or incorrect."));
|
||||||
}
|
}
|
||||||
Ok(pad)
|
Ok(pad)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub fn router(
|
|||||||
.route("/s/{token}", get(public_page))
|
.route("/s/{token}", get(public_page))
|
||||||
.route("/w/{workspace_slug}", get(workspace))
|
.route("/w/{workspace_slug}", get(workspace))
|
||||||
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
||||||
.route("/errors/private-workspace", get(private_workspace_error))
|
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/robots.txt", get(robots_txt))
|
.route("/robots.txt", get(robots_txt))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
|
|||||||
+11
-23
@@ -6,18 +6,6 @@ use axum::{
|
|||||||
|
|
||||||
use crate::{assets, db, state::SharedState};
|
use crate::{assets, db, state::SharedState};
|
||||||
|
|
||||||
pub(super) async fn private_workspace_error(State(state): State<SharedState>) -> Response {
|
|
||||||
error_response(
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"403",
|
|
||||||
"Private workspace",
|
|
||||||
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
|
|
||||||
"/",
|
|
||||||
"Home page",
|
|
||||||
&state.asset_version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn health() -> &'static str {
|
pub(super) async fn health() -> &'static str {
|
||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
@@ -112,7 +100,7 @@ pub(super) async fn workspace(
|
|||||||
match db::find_workspace(&state.db, &workspace_slug).await {
|
match db::find_workspace(&state.db, &workspace_slug).await {
|
||||||
Ok(Some(workspace)) => {
|
Ok(Some(workspace)) => {
|
||||||
let html = include_str!("../../static/workspace.html")
|
let html = include_str!("../../static/workspace.html")
|
||||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
|
.replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }));
|
||||||
assets::render_html(
|
assets::render_html(
|
||||||
&html,
|
&html,
|
||||||
&state.asset_version,
|
&state.asset_version,
|
||||||
@@ -126,8 +114,8 @@ pub(super) async fn workspace(
|
|||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
"Workspace not found",
|
"Page not found",
|
||||||
"This workspace does not exist or has been deleted.",
|
"Check the address or return to the home page.",
|
||||||
"/",
|
"/",
|
||||||
"Home page",
|
"Home page",
|
||||||
&state.asset_version,
|
&state.asset_version,
|
||||||
@@ -149,8 +137,8 @@ pub(super) async fn note(
|
|||||||
return error_response(
|
return error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
"Workspace not found",
|
"Page not found",
|
||||||
"The workspace for this note does not exist or has been deleted.",
|
"Check the address or return to the home page.",
|
||||||
"/",
|
"/",
|
||||||
"Home page",
|
"Home page",
|
||||||
&state.asset_version,
|
&state.asset_version,
|
||||||
@@ -165,8 +153,8 @@ pub(super) async fn note(
|
|||||||
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||||
Ok(Some(note)) => {
|
Ok(Some(note)) => {
|
||||||
let html = include_str!("../../static/note.html")
|
let html = include_str!("../../static/note.html")
|
||||||
.replace("__NOTE_TITLE__", &escape_html(¬e.title))
|
.replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { ¬e.title }))
|
||||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
|
.replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }))
|
||||||
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
|
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
|
||||||
assets::render_html(
|
assets::render_html(
|
||||||
&html,
|
&html,
|
||||||
@@ -181,10 +169,10 @@ pub(super) async fn note(
|
|||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
"Note not found",
|
"Page not found",
|
||||||
"This note does not exist or has been deleted.",
|
"Check the address or return to the home page.",
|
||||||
&format!("/w/{workspace_slug}"),
|
"/",
|
||||||
"Back do workspace",
|
"Home page",
|
||||||
&state.asset_version,
|
&state.asset_version,
|
||||||
),
|
),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|||||||
@@ -279,11 +279,6 @@ pub async fn identity(
|
|||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(req): Json<IdentityRequest>,
|
Json(req): Json<IdentityRequest>,
|
||||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||||
if state.ldap.is_some() && req.session_token.is_none() {
|
|
||||||
return Err(AuthError::unauthorized(
|
|
||||||
"Log in with your organization account.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
let nickname = validate_nickname(&req.nickname)?;
|
||||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||||
match find_user_by_nickname(&state, &nickname).await? {
|
match find_user_by_nickname(&state, &nickname).await? {
|
||||||
|
|||||||
@@ -185,6 +185,37 @@ pub async fn set_note_public_task_updates(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn pad_public_page_unprotected(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
|
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
|
||||||
|
}
|
||||||
|
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn note_public_page_unprotected(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
|
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
|
||||||
|
}
|
||||||
|
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pad_public_page_unprotected(pool: &Database, pad_id: i64, value: bool) -> Result<(), sqlx::Error> {
|
||||||
|
publish_pad(pool, pad_id).await?;
|
||||||
|
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q050));
|
||||||
|
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
|
||||||
|
query.bind(pad_id).execute(pool.pool()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_note_public_page_unprotected(pool: &Database, note_id: i64, value: bool) -> Result<(), sqlx::Error> {
|
||||||
|
publish_note(pool, note_id).await?;
|
||||||
|
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q051));
|
||||||
|
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
|
||||||
|
query.bind(note_id).execute(pool.pool()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_public_task(
|
pub async fn update_public_task(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ pub enum Query {
|
|||||||
Q043,
|
Q043,
|
||||||
Q044,
|
Q044,
|
||||||
Q045,
|
Q045,
|
||||||
|
Q048,
|
||||||
|
Q049,
|
||||||
|
Q050,
|
||||||
|
Q051,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
|
pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
|
||||||
@@ -267,6 +271,10 @@ pub const Q042: Query = Query::Q042;
|
|||||||
pub const Q043: Query = Query::Q043;
|
pub const Q043: Query = Query::Q043;
|
||||||
pub const Q044: Query = Query::Q044;
|
pub const Q044: Query = Query::Q044;
|
||||||
pub const Q045: Query = Query::Q045;
|
pub const Q045: Query = Query::Q045;
|
||||||
|
pub const Q048: Query = Query::Q048;
|
||||||
|
pub const Q049: Query = Query::Q049;
|
||||||
|
pub const Q050: Query = Query::Q050;
|
||||||
|
pub const Q051: Query = Query::Q051;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
|
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
|
||||||
Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
|
Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
|
||||||
Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
|
Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
|
||||||
|
|
||||||
|
Query::Q048 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
|
||||||
|
Query::Q049 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
|
||||||
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
||||||
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#,
|
Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#,
|
||||||
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
|
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
|
||||||
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
|
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
|
||||||
|
|
||||||
|
Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#,
|
||||||
|
Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#,
|
||||||
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#,
|
||||||
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
|
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
|
||||||
Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
|
Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
|
||||||
Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
|
Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
|
||||||
|
|
||||||
|
Query::Q048 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
|
||||||
|
Query::Q049 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
|
||||||
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
||||||
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,24 @@ pub struct NoteUpdate {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct PresenceUser {
|
pub struct PresenceUser {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub compact_name: String,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compact_presence_name(name: &str) -> String {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
let Some((first, rest)) = trimmed.split_once('.') else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
if first.is_empty() || rest.is_empty() {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
let Some(initial) = first.chars().next() else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
format!("{initial}.{rest}")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PresenceConnection {
|
struct PresenceConnection {
|
||||||
identity: String,
|
identity: String,
|
||||||
@@ -158,6 +173,7 @@ impl AppState {
|
|||||||
PresenceConnection {
|
PresenceConnection {
|
||||||
identity,
|
identity,
|
||||||
user: PresenceUser {
|
user: PresenceUser {
|
||||||
|
compact_name: compact_presence_name(&nickname),
|
||||||
name: nickname,
|
name: nickname,
|
||||||
color,
|
color,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ async fn handle_socket(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
|
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
|
||||||
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
||||||
let _ = send_error(&mut socket, "This workspace is private").await;
|
let _ = send_error(&mut socket, "Workspace not found").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if workspace.password_hash.is_some()
|
if workspace.password_hash.is_some()
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
let _ = send_pad(
|
let _ = send_pad(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
&PadServerMessage::Error {
|
&PadServerMessage::Error {
|
||||||
message: "This note is private".into(),
|
message: "Note not found".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+146
-2
@@ -449,11 +449,18 @@ textarea:focus {
|
|||||||
.editor-column,
|
.editor-column,
|
||||||
.preview-column {
|
.preview-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 30px minmax(0, 1fr);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-column {
|
||||||
|
grid-template-rows: 30px auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column {
|
||||||
|
grid-template-rows: 30px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-column {
|
.preview-column {
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -3993,7 +4000,6 @@ dialog::backdrop {
|
|||||||
.authorship-fragment {
|
.authorship-fragment {
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: color-mix(in srgb, var(--owner) 18%, transparent);
|
background: color-mix(in srgb, var(--owner) 18%, transparent);
|
||||||
box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent);
|
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4554,3 +4560,141 @@ dialog::backdrop {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Authorship display modes */
|
||||||
|
.editor-column-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.authorship-mode-control { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--panel) 88%, transparent); }
|
||||||
|
.authorship-mode-control button { min-height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; }
|
||||||
|
.authorship-mode-control button.active { background: var(--surface-strong, #262b35); color: var(--text); }
|
||||||
|
.participant-badges { display: flex; flex-wrap: wrap; align-items: center; align-content: center; gap: 6px; min-width: 0; min-height: 0; padding: 7px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.participant-badges[hidden] { display: none; }
|
||||||
|
.participant-badge { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; max-width: 100%; min-height: 0; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, transparent); font: 600 11px/1.3 system-ui, sans-serif; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.editor-column-label { align-items: flex-start; }
|
||||||
|
.authorship-mode-control button { padding-inline: 7px; }
|
||||||
|
.participant-badges { padding: 6px 8px; }
|
||||||
|
.public-task-toggle { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page-options { display: grid; gap: 4px; align-content: center; }
|
||||||
|
.public-page-options .public-task-toggle { min-height: 24px; }
|
||||||
|
@media (max-width: 720px) { .public-page-options { width: 100%; } }
|
||||||
|
|
||||||
|
/* Keep the whole editor surface consistent in Simple and Full modes. */
|
||||||
|
.editor-shell {
|
||||||
|
background: #0d1015;
|
||||||
|
}
|
||||||
|
.editor-shell textarea {
|
||||||
|
z-index: 3;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.authorship-layer {
|
||||||
|
z-index: 2;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.owner-labels {
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.line-gutter {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Stable editor canvas in both authorship modes. */
|
||||||
|
.pad-page .editor-column,
|
||||||
|
.pad-page .editor-shell {
|
||||||
|
background: #0d1015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-shell {
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-shell::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
background: #0d1015;
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-shell textarea,
|
||||||
|
.pad-page .owner-labels {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The authorship canvas must paint the whole editable area, not only text rows. */
|
||||||
|
.pad-page .authorship-layer {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #0d1015 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .line-gutter {
|
||||||
|
background: #0d1015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-access-rules {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, .02);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-height editor/gutter separator, independent of the number of text lines. */
|
||||||
|
.pad-page .editor-shell::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 48px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .line-gutter {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page.hide-editor-line-numbers .editor-shell::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pad-page .editor-shell::after {
|
||||||
|
left: 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fill the editor column with the actual editor shell.
|
||||||
|
The grid version could size the shell to its content in Full authorship mode,
|
||||||
|
so the gutter separator stopped after the last rendered line. */
|
||||||
|
.pad-page .editor-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-column > .column-label,
|
||||||
|
.pad-page .editor-column > .participant-badges {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-page .editor-column > .editor-shell {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
<header class="resources-panel__header">
|
<header class="resources-panel__header">
|
||||||
<h2>My notes and workspaces</h2>
|
<h2>My notes and workspaces</h2>
|
||||||
<p class="dialog-copy">Items created while signed in are assigned to your account.</p>
|
<p class="dialog-copy">Items created while signed in are assigned to your account.</p>
|
||||||
|
<p class="resources-access-rules"><strong>Access rules:</strong> Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.</p>
|
||||||
</header>
|
</header>
|
||||||
<div id="resources-list" class="resources-list"></div>
|
<div id="resources-list" class="resources-list"></div>
|
||||||
<p id="resources-error" class="form-message error" role="alert"></p>
|
<p id="resources-error" class="form-message error" role="alert"></p>
|
||||||
|
|||||||
+15
-3
@@ -126,9 +126,21 @@ async function loadResources() {
|
|||||||
message.textContent = text;
|
message.textContent = text;
|
||||||
};
|
};
|
||||||
|
|
||||||
row.querySelector("[data-privacy]")?.addEventListener("click", async () => {
|
row.querySelector("[data-privacy]")?.addEventListener("click", async event => {
|
||||||
try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); }
|
const button = event.currentTarget;
|
||||||
catch (e) { resourcesError.textContent = e.message; }
|
const nextPrivate = !Boolean(item.private);
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
|
||||||
|
item.private = nextPrivate;
|
||||||
|
button.textContent = nextPrivate ? "Make public" : "Make private";
|
||||||
|
const meta = row.querySelector(".resource-copy small");
|
||||||
|
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
|
||||||
|
} catch (e) {
|
||||||
|
resourcesError.textContent = e.message;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export function createPadAdapter() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind: "pad", slug, password }),
|
body: JSON.stringify({ kind: "pad", slug, password }),
|
||||||
}),
|
}),
|
||||||
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
|
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
|
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
|
||||||
}),
|
}),
|
||||||
loadHistory: accessToken => api(`${base}/history`, {
|
loadHistory: accessToken => api(`${base}/history`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -67,9 +67,9 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
|
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
|
||||||
}),
|
}),
|
||||||
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
|
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
|
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
|
||||||
}),
|
}),
|
||||||
loadHistory: accessToken => api(`${base}/history`, {
|
loadHistory: accessToken => api(`${base}/history`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
+63
-31
@@ -17,9 +17,9 @@ 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"), mobileChatUnread = document.querySelector("#mobile-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"), mobileChatUnread = document.querySelector("#mobile-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"), useGlobalColorButton = document.querySelector("#use-global-color");
|
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), 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, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
|
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = ["full", "advanced"].includes(localStorage.getItem("rustpad:authorship-mode")) ? "full" : "simple";
|
||||||
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
||||||
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
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";
|
||||||
@@ -61,7 +61,7 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
updateCurrentUser(); return info;
|
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 : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { 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); } renderGutter(); }
|
||||||
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))); }
|
||||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||||
@@ -71,48 +71,55 @@ export function startNoteEditor(adapter) {
|
|||||||
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
||||||
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||||
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
||||||
|
function renderParticipantBadges(owners) {
|
||||||
|
if (!participantBadges) return;
|
||||||
|
const people = new Map();
|
||||||
|
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
|
||||||
|
for (const user of presenceUsers) {
|
||||||
|
const name = user.name || "Guest";
|
||||||
|
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
|
||||||
|
people.set(name, { name, compactName: user.compact_name || name, color });
|
||||||
|
}
|
||||||
|
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
|
||||||
|
const compact = people.size > 4;
|
||||||
|
participantBadges.replaceChildren(...[...people.values()].map(person => {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "participant-badge";
|
||||||
|
badge.style.setProperty("--owner", person.color);
|
||||||
|
badge.textContent = compact && person.compactName ? person.compactName : person.name;
|
||||||
|
badge.title = person.name;
|
||||||
|
return badge;
|
||||||
|
}));
|
||||||
|
}
|
||||||
function renderGutter() {
|
function renderGutter() {
|
||||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||||
const lines = Array.from({ length: lineCount });
|
const lines = Array.from({ length: lineCount });
|
||||||
const owners = authorshipOwners(authorship);
|
const owners = authorshipOwners(authorship);
|
||||||
const showSingleOwner = owners.length === 1;
|
const showAuthorship = owners.length > 0;
|
||||||
const showAuthorship = owners.length > 1;
|
|
||||||
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
||||||
|
const full = authorshipMode === "full";
|
||||||
authorshipLayer.hidden = !showAuthorship;
|
authorshipLayer.hidden = !showAuthorship;
|
||||||
ownerLabels.hidden = !(showSingleOwner || showAuthorship);
|
ownerLabels.hidden = !full || !showAuthorship;
|
||||||
|
renderParticipantBadges(owners);
|
||||||
|
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
|
||||||
|
editorWorkspace.dataset.authorshipMode = authorshipMode;
|
||||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||||
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
|
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
|
||||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||||
if (showSingleOwner) {
|
if (full) {
|
||||||
const owner = owners[0];
|
|
||||||
const top = paddingTop - editor.scrollTop;
|
|
||||||
const markerHeight = Math.max(lineHeight, lineCount * lineHeight);
|
|
||||||
const ownerColor = colorFor(owner);
|
|
||||||
ownerLabels.innerHTML = `<span class="owner-line owner-line--document" style="top:${top}px;height:${markerHeight}px;--owner:${ownerColor}"></span><span class="owner-label-group" style="top:${top}px"><span class="owner-label" style="--owner:${ownerColor}">${escapeHtml(ownerName(owner))}</span></span>`;
|
|
||||||
} else {
|
|
||||||
let previousAuthorSignature = null;
|
let previousAuthorSignature = null;
|
||||||
ownerLabels.innerHTML = lines.map((_, i) => {
|
ownerLabels.innerHTML = lines.map((_, i) => {
|
||||||
const authors = authorsByLine[i] || [];
|
const authors = authorsByLine[i] || [];
|
||||||
if (!authors.length) return "";
|
if (!authors.length) return "";
|
||||||
|
|
||||||
const top = paddingTop + i * lineHeight - editor.scrollTop;
|
const top = paddingTop + i * lineHeight - editor.scrollTop;
|
||||||
const signature = authors
|
const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
|
||||||
.map(owner => ownerName(owner))
|
if (signature === previousAuthorSignature) return "";
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
.join("\u0000");
|
|
||||||
const startsOwnershipBlock = signature !== previousAuthorSignature;
|
|
||||||
previousAuthorSignature = signature;
|
previousAuthorSignature = signature;
|
||||||
|
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
|
||||||
const lineMarker = `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span>`;
|
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
|
||||||
if (!startsOwnershipBlock) return lineMarker;
|
|
||||||
|
|
||||||
const badges = authors
|
|
||||||
.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`)
|
|
||||||
.join("");
|
|
||||||
return `${lineMarker}<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
|
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
} else ownerLabels.replaceChildren();
|
||||||
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
||||||
else authorshipLayer.replaceChildren();
|
else authorshipLayer.replaceChildren();
|
||||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||||
@@ -212,6 +219,11 @@ export function startNoteEditor(adapter) {
|
|||||||
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
||||||
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
||||||
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
|
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
|
||||||
|
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
|
||||||
|
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
|
||||||
|
localStorage.setItem("rustpad:authorship-mode", authorshipMode);
|
||||||
|
renderGutter();
|
||||||
|
}));
|
||||||
document.querySelectorAll("[data-view]").forEach(button => {
|
document.querySelectorAll("[data-view]").forEach(button => {
|
||||||
const active = button.dataset.view === view;
|
const active = button.dataset.view === view;
|
||||||
button.classList.toggle("active", active);
|
button.classList.toggle("active", active);
|
||||||
@@ -233,6 +245,21 @@ export function startNoteEditor(adapter) {
|
|||||||
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
||||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||||
|
async function showSystemNotFound() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const html = await response.text();
|
||||||
|
document.open();
|
||||||
|
document.write(html);
|
||||||
|
document.close();
|
||||||
|
} catch {
|
||||||
|
document.body.textContent = "404 Not Found";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
try {
|
try {
|
||||||
if (getAuthToken()) {
|
if (getAuthToken()) {
|
||||||
@@ -247,14 +274,18 @@ export function startNoteEditor(adapter) {
|
|||||||
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
|
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
|
||||||
await loadNoteInfo();
|
await loadNoteInfo();
|
||||||
document.title = adapter.title(info);
|
document.title = adapter.title(info);
|
||||||
publicTaskUpdates.checked = Boolean(info.allow_public_task_updates);
|
publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected);
|
||||||
adapter.configureView?.(info);
|
adapter.configureView?.(info);
|
||||||
applyUi({ write: true, replace: true });
|
applyUi({ write: true, replace: true });
|
||||||
updateCurrentUser();
|
updateCurrentUser();
|
||||||
if (info.protected && !accessToken) passwordDialog.showModal();
|
if (info.protected && !accessToken) passwordDialog.showModal();
|
||||||
else { loadFiles(); connect(); }
|
else { loadFiles(); connect(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;
|
if (e.status === 403 || e.status === 404) {
|
||||||
|
await showSystemNotFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +395,8 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
|
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
|
||||||
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); } });
|
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked);
|
||||||
|
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { unprotectPublicPage.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await savePublicOptions(); 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(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } });
|
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } });
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
|
||||||
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(); });
|
||||||
|
|||||||
+8
-2
@@ -9,6 +9,10 @@ import { toast } from "@rustpad/toast";
|
|||||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||||
const content = document.querySelector("#public-content");
|
const content = document.querySelector("#public-content");
|
||||||
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
||||||
|
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
|
||||||
|
const passwordKey = `rustpad:public-page-password:${token}`;
|
||||||
|
let pagePassword = sessionStorage.getItem(passwordKey) || "";
|
||||||
|
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
|
||||||
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||||
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
||||||
function lockPublicContent(allowTaskUpdates) {
|
function lockPublicContent(allowTaskUpdates) {
|
||||||
@@ -26,7 +30,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
|
|||||||
target.scrollIntoView({ behavior, block: "start" });
|
target.scrollIntoView({ behavior, block: "start" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
|
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
|
||||||
content.addEventListener("click", event => {
|
content.addEventListener("click", event => {
|
||||||
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
@@ -36,7 +40,9 @@ content.addEventListener("click", event => {
|
|||||||
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
|
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
|
||||||
});
|
});
|
||||||
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
|
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
|
||||||
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
|
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
|
||||||
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
||||||
|
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; sessionStorage.setItem(passwordKey, pagePassword); await initialize(); });
|
||||||
|
passwordDialog.addEventListener("cancel", event => event.preventDefault());
|
||||||
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
|
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
|
||||||
initialize();
|
initialize();
|
||||||
|
|||||||
+17
-3
@@ -92,6 +92,20 @@ function renderNotes(notes = notesCache) {
|
|||||||
${deleteButton(note)}
|
${deleteButton(note)}
|
||||||
</article>`).join("");
|
</article>`).join("");
|
||||||
}
|
}
|
||||||
|
async function showSystemNotFound() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const html = await response.text();
|
||||||
|
document.open();
|
||||||
|
document.write(html);
|
||||||
|
document.close();
|
||||||
|
} catch {
|
||||||
|
document.body.textContent = "404 Not Found";
|
||||||
|
}
|
||||||
|
}
|
||||||
async function openWorkspace() {
|
async function openWorkspace() {
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
|
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
|
||||||
@@ -106,8 +120,8 @@ async function openWorkspace() {
|
|||||||
if (info?.protected || e.message.toLowerCase().includes("password")) {
|
if (info?.protected || e.message.toLowerCase().includes("password")) {
|
||||||
document.querySelector("#password-error").textContent = e.message;
|
document.querySelector("#password-error").textContent = e.message;
|
||||||
if (!dialog.open) dialog.showModal();
|
if (!dialog.open) dialog.showModal();
|
||||||
} else if (e.status === 403) {
|
} else if (e.status === 403 || e.status === 404) {
|
||||||
location.replace("/errors/private-workspace");
|
await showSystemNotFound();
|
||||||
} else document.querySelector("#workspace-error").textContent = e.message;
|
} else document.querySelector("#workspace-error").textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +133,7 @@ async function init() {
|
|||||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||||
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
|
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 403) location.replace("/errors/private-workspace");
|
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
||||||
else document.querySelector("#workspace-error").textContent = e.message;
|
else document.querySelector("#workspace-error").textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -31,9 +31,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<div id="header-actions" class="header-actions"><button id="copy-link"
|
<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><div class="public-page-options"><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><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><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>
|
</div>
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="editor-workspace" class="workspace view-split">
|
<div id="editor-workspace" class="workspace view-split">
|
||||||
<div class="editor-column">
|
<div class="editor-column">
|
||||||
<div class="column-label editor-column-label"><span>Editor</span></div>
|
<div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
|
||||||
<div class="editor-shell">
|
<div class="editor-shell">
|
||||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
|
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
|
||||||
|
|||||||
+3
-3
@@ -32,10 +32,10 @@
|
|||||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
||||||
link</button><button id="publish-page" class="secondary-button">Page</button><label
|
link</button><button id="publish-page" class="secondary-button">Page</button><div class="public-page-options"><label
|
||||||
class="public-task-toggle"
|
class="public-task-toggle"
|
||||||
title="Allow visitors to update task checkboxes on the published page"><input
|
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="public-task-updates" type="checkbox"> Editable tasks on Page</label><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><button
|
||||||
id="files-button" class="secondary-button">Files</button><button id="history-button"
|
id="files-button" class="secondary-button">Files</button><button id="history-button"
|
||||||
class="secondary-button">History</button></div>
|
class="secondary-button">History</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="editor-workspace" class="workspace view-split">
|
<div id="editor-workspace" class="workspace view-split">
|
||||||
<div class="editor-column">
|
<div class="editor-column">
|
||||||
<div class="column-label editor-column-label"><span>Editor</span></div>
|
<div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
|
||||||
<div class="editor-shell">
|
<div class="editor-shell">
|
||||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
<p id="public-meta" class="public-meta"></p>
|
<p id="public-meta" class="public-meta"></p>
|
||||||
<article id="public-content" class="markdown-body public-content"></article>
|
<article id="public-content" class="markdown-body public-content"></article>
|
||||||
</main>
|
</main>
|
||||||
|
<dialog id="public-password-dialog">
|
||||||
|
<form id="public-password-form" class="dialog-panel">
|
||||||
|
<h2>Protected page</h2>
|
||||||
|
<p>Enter the note password or sign in with an account that has access.</p>
|
||||||
|
<input id="public-password" type="password" autocomplete="current-password" minlength="8" maxlength="128" placeholder="Password">
|
||||||
|
<p id="public-password-error" class="form-message error"></p>
|
||||||
|
<button class="primary-button">Open page</button>
|
||||||
|
<a class="text-button" href="/">Back to home</a>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user