new finctions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 00:12:48 +02:00
parent 9ca055de5f
commit 2274cf57c9
31 changed files with 1348 additions and 505 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.3" version = "0.2.4"
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"
+5 -2
View File
@@ -15,10 +15,11 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
- Real-time collaborative editing over WebSocket. - Real-time collaborative editing over WebSocket.
- Nicknames stored in `localStorage`. - Nicknames stored in `localStorage`.
- Change authors shown in history. - Change authors shown in history.
- Line numbering enabled by default, with a persistent toggle. - Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account.
- Owner color displayed next to each line. - Owner color displayed next to each line.
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`. - Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
- Automatic Markdown link insertion after upload. - Compact attachment aliases are inserted after upload: `[file=name.ext,label]` and `[image=name.ext,alt]`. The file dialog also provides standard Markdown for compatibility.
- Markdown and Mermaid diagram rendering. - Markdown and Mermaid diagram rendering.
- History with snippets, previews, and version restore. - History with snippets, previews, and version restore.
- Alert blocks: `success`, `info`, `warning`, and `danger`. - Alert blocks: `success`, `info`, `warning`, and `danger`.
@@ -150,6 +151,8 @@ RustPad supports two interchangeable attachment backends selected in `.env`:
Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration. Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration.
Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=<seconds>`.
For the optional Docker Garage service, configure the S3 variables shown in `.env.example`, use strong unique credentials, and run: For the optional Docker Garage service, configure the S3 variables shown in `.env.example`, use strong unique credentials, and run:
```sh ```sh
@@ -0,0 +1,25 @@
CREATE TABLE user_editor_preferences (
user_id BIGINT NOT NULL,
pad_id BIGINT NULL,
note_id BIGINT NULL,
authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
compact_view BOOLEAN NOT NULL DEFAULT TRUE,
editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE,
preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE,
line_links BOOLEAN NOT NULL DEFAULT FALSE,
font_family VARCHAR(16) NOT NULL DEFAULT 'mono',
font_size BIGINT NOT NULL DEFAULT 14,
updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT fk_user_editor_preferences_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_editor_preferences_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
CONSTRAINT fk_user_editor_preferences_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CONSTRAINT chk_user_editor_preferences_resource CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE KEY uq_user_editor_preferences_pad (user_id, pad_id),
UNIQUE KEY uq_user_editor_preferences_note (user_id, note_id),
INDEX idx_user_editor_preferences_user (user_id),
INDEX idx_user_editor_preferences_pad (pad_id),
INDEX idx_user_editor_preferences_note (note_id)
) ENGINE=InnoDB;
DROP TABLE resource_editor_settings;
@@ -0,0 +1,40 @@
CREATE TABLE resource_editor_settings (
resource_kind VARCHAR(32) NOT NULL,
resource_slug VARCHAR(512) NOT NULL,
authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled,
CURRENT_TIMESTAMP
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL
ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', CONCAT(workspace.slug, '/', note.slug),
preferences.authorship_mode, preferences.colors_enabled,
CURRENT_TIMESTAMP
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL
ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug);
ALTER TABLE user_editor_preferences
DROP COLUMN authorship_mode,
DROP COLUMN colors_enabled;
@@ -0,0 +1,23 @@
CREATE TABLE user_editor_preferences (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pad_id BIGINT REFERENCES pads(id) ON DELETE CASCADE,
note_id BIGINT REFERENCES notes(id) ON DELETE CASCADE,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
compact_view BOOLEAN NOT NULL DEFAULT TRUE,
editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE,
preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE,
line_links BOOLEAN NOT NULL DEFAULT FALSE,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size BIGINT NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
DROP TABLE resource_editor_settings;
@@ -0,0 +1,40 @@
CREATE TABLE resource_editor_settings (
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled,
preferences.updated_at::timestamptz
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL
ON CONFLICT (resource_kind, resource_slug) DO NOTHING;
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', workspace.slug || '/' || note.slug,
preferences.authorship_mode, preferences.colors_enabled,
preferences.updated_at::timestamptz
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL
ON CONFLICT (resource_kind, resource_slug) DO NOTHING;
ALTER TABLE user_editor_preferences
DROP COLUMN authorship_mode,
DROP COLUMN colors_enabled;
@@ -0,0 +1,26 @@
CREATE TABLE user_editor_preferences (
user_id INTEGER NOT NULL,
pad_id INTEGER,
note_id INTEGER,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled INTEGER NOT NULL DEFAULT 1,
compact_view INTEGER NOT NULL DEFAULT 1,
editor_line_numbers INTEGER NOT NULL DEFAULT 1,
preview_line_numbers INTEGER NOT NULL DEFAULT 0,
line_links INTEGER NOT NULL DEFAULT 0,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size INTEGER NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
DROP TABLE resource_editor_settings;
@@ -0,0 +1,67 @@
CREATE TABLE resource_editor_settings (
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT OR IGNORE INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL;
INSERT OR IGNORE INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', workspace.slug || '/' || note.slug,
preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL;
ALTER TABLE user_editor_preferences RENAME TO user_editor_preferences_legacy;
CREATE TABLE user_editor_preferences (
user_id INTEGER NOT NULL,
pad_id INTEGER,
note_id INTEGER,
compact_view INTEGER NOT NULL DEFAULT 1,
editor_line_numbers INTEGER NOT NULL DEFAULT 1,
preview_line_numbers INTEGER NOT NULL DEFAULT 0,
line_links INTEGER NOT NULL DEFAULT 0,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size INTEGER NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
INSERT INTO user_editor_preferences (
user_id, pad_id, note_id, compact_view, editor_line_numbers,
preview_line_numbers, line_links, font_family, font_size, updated_at
)
SELECT user_id, pad_id, note_id, compact_view, editor_line_numbers,
preview_line_numbers, line_links, font_family, font_size, updated_at
FROM user_editor_preferences_legacy;
DROP TABLE user_editor_preferences_legacy;
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
+37 -9
View File
@@ -101,14 +101,37 @@ pub async fn upload_pad_file(
let mime = mime_guess::from_path(&stored) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state state
.storage .storage
.put(&key, bytes.clone().into(), &mime, &cache_control) .put(&key, bytes.clone().into(), &mime, &cache_control)
.await .await
.map_err(|_| ApiError::internal("Failed to save the file"))?; .map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url}))) Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
}
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
let end = after
.find(|character| character == ',' || character == ']')
.unwrap_or(after.len());
if after[..end].trim() == filename {
return true;
}
if end >= after.len() {
break;
}
remaining = &after[end + 1..];
}
}
false
} }
pub async fn pad_files( pub async fn pad_files(
@@ -127,7 +150,7 @@ pub async fn pad_files(
.await?; .await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?; let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files { for file in &mut files {
let attached = pad.content.contains(&file.url); let attached = content_references_file(&pad.content, &file.filename, &file.url);
if attached != file.is_attached { if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?; db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
@@ -265,14 +288,14 @@ pub async fn upload_note_file(
let mime = mime_guess::from_path(&stored) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state state
.storage .storage
.put(&key, bytes.clone().into(), &mime, &cache_control) .put(&key, bytes.clone().into(), &mime, &cache_control)
.await .await
.map_err(|_| ApiError::internal("Failed to save the file"))?; .map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url}))) Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
} }
pub async fn delete_note( pub async fn delete_note(
@@ -316,6 +339,12 @@ pub async fn delete_note(
.await .await
.map_err(|_| ApiError::internal("Failed to delete note files"))?; .map_err(|_| ApiError::internal("Failed to delete note files"))?;
} }
db::delete_resource_editor_state(
&state.db,
"note",
&format!("{workspace_slug}/{note_slug}"),
)
.await?;
db::delete_note(&state.db, note.id).await?; db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true}))) Ok(Json(serde_json::json!({"ok": true})))
} }
@@ -337,7 +366,7 @@ pub async fn note_files(
.await?; .await?;
let mut files = db::list_note_files(&state.db, note.id).await?; let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files { for file in &mut files {
let attached = note.content.contains(&file.url); let attached = content_references_file(&note.content, &file.filename, &file.url);
if attached != file.is_attached { if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?; db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
@@ -464,9 +493,8 @@ async fn serve_token_file(
); );
response.headers_mut().insert( response.headers_mut().insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_str(&format!( HeaderValue::from_str(&crate::cache::cache_control(
"public, max-age={}", state.file_cache_max_age_seconds,
state.file_cache_max_age_seconds
)) ))
.expect("valid file cache-control header"), .expect("valid file cache-control header"),
); );
+259 -83
View File
@@ -48,18 +48,99 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
} }
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("x-rustpad-user-token")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| bearer_token(headers))
}
async fn session_user(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<crate::auth::User>, ApiError> {
let Some(token) = user_session_token(headers) else {
return Ok(None);
};
crate::auth::user_from_token(state, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))
}
async fn has_write_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
let bearer = bearer_token(headers);
if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write {
return Ok(true);
}
let session = user_session_token(headers);
if session.is_some() && session != bearer {
return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
}
Ok(false)
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PublishResponse { pub struct PublishResponse {
url: Option<String>, url: Option<String>,
enabled: bool, enabled: bool,
} }
#[derive(Debug, Clone, Serialize)]
pub struct MarkdownFileReference {
filename: String,
url: String,
mime_type: String,
}
impl From<db::NoteFile> for MarkdownFileReference {
fn from(file: db::NoteFile) -> Self {
Self {
filename: file.filename,
url: file.url,
mime_type: file.mime_type,
}
}
}
pub(crate) async fn markdown_file_references(
state: &SharedState,
pad_id: Option<i64>,
note_id: Option<i64>,
content: Option<&str>,
) -> Result<Vec<MarkdownFileReference>, ApiError> {
let file_rows = if let Some(id) = pad_id {
db::list_pad_files(&state.db, id).await?
} else if let Some(id) = note_id {
db::list_note_files(&state.db, id).await?
} else {
Vec::new()
};
Ok(file_rows
.into_iter()
.filter(|file| {
content
.map(|value| files::content_references_file(value, &file.filename, &file.url))
.unwrap_or(true)
})
.map(Into::into)
.collect())
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PublicPageResponse { pub struct PublicPageResponse {
title: String, title: String,
content: String, content: String,
updated_at: String, updated_at: String,
allow_task_updates: bool, allow_task_updates: bool,
files: Vec<MarkdownFileReference>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -173,7 +254,16 @@ pub struct NoteInfo {
note_color: Option<String>, note_color: Option<String>,
authorship_mode: String, authorship_mode: String,
colors_enabled: bool, colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool, can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -183,35 +273,38 @@ pub struct EditorColorRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct EditorSettingsRequest { pub struct EditorSettingsRequest {
authorship_mode: String, #[serde(default)]
colors_enabled: bool, authorship_mode: Option<String>,
#[serde(default)]
colors_enabled: Option<bool>,
#[serde(default)]
compact_view: Option<bool>,
#[serde(default)]
editor_line_numbers: Option<bool>,
#[serde(default)]
preview_line_numbers: Option<bool>,
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
} }
async fn editor_settings( async fn user_editor_preferences(
state: &SharedState, state: &SharedState,
kind: &str, headers: &HeaderMap,
slug: &str, resource: db::EditorPreferenceResource,
) -> Result<(String, bool), ApiError> { ) -> Result<(db::EditorPreferences, bool), ApiError> {
let row: Option<(String, i64)> = sqlx::query_as(queries::get( let Some(user) = session_user(state, headers).await? else {
state.db.kind(), return Ok((db::EditorPreferences::default(), false));
queries::RESOURCE_EDITOR_SETTINGS_SELECT, };
Ok((
db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default(),
true,
)) ))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await?;
Ok(row
.map(|(mode, colors)| {
(
if mode == "full" {
"full".into()
} else {
"simple".into()
},
colors != 0,
)
})
.unwrap_or_else(|| ("simple".into(), true)))
} }
async fn save_editor_settings( async fn save_editor_settings(
@@ -221,49 +314,123 @@ async fn save_editor_settings(
permission_slug: &str, permission_slug: &str,
settings_kind: &str, settings_kind: &str,
settings_slug: &str, settings_slug: &str,
resource: db::EditorPreferenceResource,
payload: EditorSettingsRequest, payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let permission = crate::auth::resource_permission( if !has_write_permission(
state,
headers,
permission_kind,
permission_slug,
)
.await?
{
return Err(ApiError::forbidden(
"Read and write access is required to save editor preferences",
));
}
let user = session_user(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?;
let wants_personal_update = payload.compact_view.is_some()
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
if !wants_personal_update && !wants_global_update {
return Err(ApiError::bad_request("No editor settings were provided"));
}
let can_manage_authorship = if wants_global_update {
crate::auth::is_resource_owner(
state, state,
permission_kind, permission_kind,
permission_slug, permission_slug,
bearer_token(headers), user_session_token(headers),
) )
.await .await
.map_err(|e| ApiError::forbidden(&e.message))?; .unwrap_or(false)
if permission.as_deref() != Some("rw") { } else {
false
};
if wants_global_update && !can_manage_authorship {
return Err(ApiError::forbidden( return Err(ApiError::forbidden(
"Read and write access is required to save editor settings", "Only the resource owner can change authorship settings",
)); ));
} }
let mode = match payload.authorship_mode.as_str() {
"simple" => "simple", let preferences = if wants_personal_update {
"full" | "advanced" => "full", let mut preferences = db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default();
if let Some(value) = payload.compact_view {
preferences.compact_view = value;
}
if let Some(value) = payload.editor_line_numbers {
preferences.editor_line_numbers = value;
}
if let Some(value) = payload.preview_line_numbers {
preferences.preview_line_numbers = value;
}
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
_ => return Err(ApiError::bad_request("Invalid editor font")),
};
}
if let Some(value) = payload.font_size {
if !matches!(value, 14 | 16 | 18 | 20 | 22) {
return Err(ApiError::bad_request("Invalid editor font size"));
}
preferences.font_size = value;
}
Some(preferences)
} else {
None
};
let resource_settings = if wants_global_update {
let mut settings = db::load_resource_editor_settings(
&state.db,
settings_kind,
settings_slug,
)
.await?;
if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(),
"full" | "advanced" => "full".into(),
_ => return Err(ApiError::bad_request("Invalid authorship mode")), _ => return Err(ApiError::bad_request("Invalid authorship mode")),
}; };
let mut tx = state.db.pool().begin().await?; }
sqlx::query(queries::get( if let Some(value) = payload.colors_enabled {
state.db.kind(), settings.colors_enabled = value;
queries::RESOURCE_EDITOR_SETTINGS_DELETE, }
)) Some(settings)
.bind(settings_kind) } else {
.bind(settings_slug) None
.execute(&mut *tx) };
db::save_editor_configuration(
&state.db,
user.id,
resource,
preferences.as_ref(),
resource_settings
.as_ref()
.map(|settings| (settings_kind, settings_slug, settings)),
)
.await?; .await?;
sqlx::query(queries::get(
state.db.kind(), Ok(Json(serde_json::json!({
queries::RESOURCE_EDITOR_SETTINGS_INSERT, "preferences": preferences,
)) "resource_settings": resource_settings,
.bind(settings_kind) })))
.bind(settings_slug)
.bind(mode)
.bind(payload.colors_enabled)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}),
))
} }
pub async fn create_workspace( pub async fn create_workspace(
@@ -456,10 +623,7 @@ async fn editor_colors(
kind: &str, kind: &str,
slug: &str, slug: &str,
) -> Result<(Option<String>, Option<String>), ApiError> { ) -> Result<(Option<String>, Option<String>), ApiError> {
let Some(user) = crate::auth::optional_user(state, headers) let Some(user) = session_user(state, headers).await? else {
.await
.map_err(|e| ApiError::forbidden(&e.message))?
else {
return Ok((None, None)); return Ok((None, None));
}; };
let global: Option<String> = sqlx::query_scalar(queries::get( let global: Option<String> = sqlx::query_scalar(queries::get(
@@ -488,9 +652,8 @@ async fn save_editor_color(
slug: &str, slug: &str,
color: Option<&str>, color: Option<&str>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let user = crate::auth::optional_user(state, headers) let user = session_user(state, headers)
.await .await?
.map_err(|e| ApiError::forbidden(&e.message))?
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
let color = clean_editor_color(color)?; let color = clean_editor_color(color)?;
let mut tx = state.db.pool().begin().await?; let mut tx = state.db.pool().begin().await?;
@@ -540,18 +703,24 @@ pub async fn note_info(
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let color_slug = format!("{}/{}", workspace_slug, note_slug); let color_slug = format!("{}/{}", workspace_slug, note_slug);
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "note", &color_slug).await?; let (editor_preferences, personal_editor_settings) = user_editor_preferences(
let can_save_editor_settings = crate::auth::resource_permission( &state,
&headers,
db::EditorPreferenceResource::Note(note.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
let can_save_editor_settings = personal_editor_settings
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state, &state,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
bearer_token(&headers), user_session_token(&headers),
) )
.await .await
.ok() .unwrap_or(false);
.flatten()
.as_deref()
== Some("rw");
if workspace.is_private == 0 if workspace.is_private == 0
&& !db::note_public_page_disabled(&state.db, note.id).await? && !db::note_public_page_disabled(&state.db, note.id).await?
@@ -573,15 +742,7 @@ pub async fn note_info(
created_at: db::normalize_timestamp(&note.created_at), created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at), updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: { can_delete_files: {
let workspace_owner = crate::auth::is_resource_owner( let note_owner = session_user(&state, &headers)
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
.await .await
.ok() .ok()
.flatten() .flatten()
@@ -591,13 +752,22 @@ pub async fn note_info(
.map(|creator| creator == user.nickname) .map(|creator| creator == user.nickname)
}) })
.unwrap_or(false); .unwrap_or(false);
workspace_owner || note_owner can_manage_authorship || note_owner
}, },
global_color, global_color,
note_color, note_color,
authorship_mode, authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled, colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings, can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, None, Some(note.id), None).await?,
})) }))
} }
@@ -607,14 +777,20 @@ pub async fn set_note_editor_settings(
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorSettingsRequest>, Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let settings_slug = format!("{}/{}", workspace_slug, note_slug); let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings( save_editor_settings(
&state, &state,
&headers, &headers,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
"note", "note",
&settings_slug, &format!("{workspace_slug}/{note_slug}"),
db::EditorPreferenceResource::Note(note.id),
payload, payload,
) )
.await .await
+68 -18
View File
@@ -38,7 +38,16 @@ pub struct PadInfo {
note_color: Option<String>, note_color: Option<String>,
authorship_mode: String, authorship_mode: String,
colors_enabled: bool, colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool, can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
} }
pub async fn create_pad( pub async fn create_pad(
@@ -92,14 +101,24 @@ pub async fn pad_info(
) )
.await?; .await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "pad", &slug).await?; let (editor_preferences, personal_editor_settings) = user_editor_preferences(
let can_save_editor_settings = &state,
crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers)) &headers,
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"pad",
&slug,
user_session_token(&headers),
)
.await .await
.ok() .unwrap_or(false);
.flatten() let can_save_editor_settings = personal_editor_settings
.as_deref() && has_write_permission(&state, &headers, "pad", &slug).await?;
== Some("rw");
if pad.is_private == 0 if pad.is_private == 0
&& !db::pad_public_page_disabled(&state.db, pad.id).await? && !db::pad_public_page_disabled(&state.db, pad.id).await?
&& !db::pad_public_page_enabled(&state.db, pad.id).await? && !db::pad_public_page_enabled(&state.db, pad.id).await?
@@ -116,19 +135,21 @@ pub async fn pad_info(
private: pad.is_private != 0, private: pad.is_private != 0,
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: can_manage_authorship,
&state,
"pad",
&slug,
bearer_token(&headers),
)
.await
.unwrap_or(false),
global_color, global_color,
note_color, note_color,
authorship_mode, authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled, colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings, can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
})) }))
} }
@@ -138,7 +159,20 @@ pub async fn set_pad_editor_settings(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<EditorSettingsRequest>, Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings(
&state,
&headers,
"pad",
&slug,
"pad",
&slug,
db::EditorPreferenceResource::Pad(pad.id),
payload,
)
.await
} }
pub async fn pad_editor_color( pub async fn pad_editor_color(
@@ -382,11 +416,19 @@ pub async fn public_page(
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?; ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at), updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates, allow_task_updates: page.allow_task_updates,
files,
})) }))
} }
@@ -408,11 +450,19 @@ pub async fn update_public_task(
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked) let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at), updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates, allow_task_updates: page.allow_task_updates,
files,
})) }))
} }
+3 -2
View File
@@ -46,8 +46,9 @@ pub fn router(
} }
}); });
let asset_cache_control = let asset_cache_control = HeaderValue::from_str(&crate::cache::cache_control(
HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) asset_cache_max_age_seconds,
))
.expect("valid asset cache-control header"); .expect("valid asset cache-control header");
Router::new() Router::new()
+50 -40
View File
@@ -15,6 +15,36 @@ use axum::{
use crate::{assets, db, state::SharedState}; use crate::{assets, db, state::SharedState};
fn render_editor_page(
state: &SharedState,
entrypoint: &str,
resource_kind: &str,
document_title: &str,
parent_title: &str,
parent_url: &str,
parent_class: &str,
protected_resource_label: &str,
extra_shortcuts: &str,
) -> Response {
let html = include_str!("../../static/editor.html")
.replace("__RESOURCE_KIND__", resource_kind)
.replace("__DOCUMENT_TITLE__", &escape_html(document_title))
.replace("__PARENT_TITLE__", &escape_html(parent_title))
.replace("__PARENT_URL__", &escape_html(parent_url))
.replace("__PARENT_CLASS__", parent_class)
.replace("__PROTECTED_RESOURCE_LABEL__", protected_resource_label)
.replace("__EXTRA_SHORTCUTS__", extra_shortcuts);
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
entrypoint,
)
}
pub(super) async fn health() -> &'static str { pub(super) async fn health() -> &'static str {
"ok" "ok"
} }
@@ -46,19 +76,17 @@ pub(super) async fn home(State(state): State<SharedState>) -> Response {
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response { pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
match db::find_pad(&state.db, &slug).await { match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => { Ok(Some(pad)) => render_editor_page(
let html = include_str!("../../static/pad.html") &state,
.replace("__PAD_TITLE__", &escape_html(&pad.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"pad", "pad",
) "pad",
} &pad.title,
"RustPad",
"/",
"home-brand",
"note",
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
),
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -169,35 +197,17 @@ pub(super) async fn note(
}; };
match db::find_note(&state.db, workspace.id, &note_slug).await { match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => { Ok(Some(note)) => render_editor_page(
let html = include_str!("../../static/note.html") &state,
.replace(
"__NOTE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Note"
} else {
&note.title
}),
)
.replace(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
}),
)
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"note", "note",
) "note",
} if workspace.is_private != 0 { "Note" } else { &note.title },
if workspace.is_private != 0 { "Workspace" } else { &workspace.title },
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
),
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
+26
View File
@@ -944,6 +944,22 @@ pub async fn confirm_account_action(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLORS_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::EDITOR_PREFERENCES_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER)) sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER))
.bind(&deleted_nickname) .bind(&deleted_nickname)
.bind(normalize(&deleted_nickname)) .bind(normalize(&deleted_nickname))
@@ -1143,6 +1159,13 @@ pub async fn delete_resource(
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
for note in notes { for note in notes {
crate::db::delete_resource_editor_state(
&state.db,
"note",
&format!("{}/{}", req.slug.trim(), note.slug),
)
.await
.map_err(AuthError::database)?;
let files = crate::db::list_note_files(&state.db, note.id) let files = crate::db::list_note_files(&state.db, note.id)
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
@@ -1161,6 +1184,9 @@ pub async fn delete_resource(
queries::USER_DELETE_WORKSPACE queries::USER_DELETE_WORKSPACE
} }
"pad" => { "pad" => {
crate::db::delete_resource_editor_state(&state.db, "pad", req.slug.trim())
.await
.map_err(AuthError::database)?;
if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim()) if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim())
.await .await
.map_err(AuthError::database)? .map_err(AuthError::database)?
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
pub const DISABLED_CACHE_CONTROL: &str = "no-cache, no-store, must-revalidate";
pub fn cache_control(max_age_seconds: u64) -> String {
if max_age_seconds == 0 {
DISABLED_CACHE_CONTROL.into()
} else {
format!("public, max-age={max_age_seconds}")
}
}
+193
View File
@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
use super::*;
#[derive(Debug, Clone, Serialize)]
pub struct EditorPreferences {
pub compact_view: bool,
pub editor_line_numbers: bool,
pub preview_line_numbers: bool,
pub line_links: bool,
pub font_family: String,
pub font_size: i64,
}
impl Default for EditorPreferences {
fn default() -> Self {
Self {
compact_view: true,
editor_line_numbers: true,
preview_line_numbers: false,
line_links: false,
font_family: "mono".into(),
font_size: 14,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ResourceEditorSettings {
pub authorship_mode: String,
pub colors_enabled: bool,
}
impl Default for ResourceEditorSettings {
fn default() -> Self {
Self {
authorship_mode: "simple".into(),
colors_enabled: true,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum EditorPreferenceResource {
Pad(i64),
Note(i64),
}
fn preference_select_query(resource: EditorPreferenceResource) -> queries::Query {
match resource {
EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_SELECT_PAD,
EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_SELECT_NOTE,
}
}
fn preference_upsert_query(resource: EditorPreferenceResource) -> queries::Query {
match resource {
EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_UPSERT_PAD,
EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_UPSERT_NOTE,
}
}
fn resource_id(resource: EditorPreferenceResource) -> i64 {
match resource {
EditorPreferenceResource::Pad(id) | EditorPreferenceResource::Note(id) => id,
}
}
pub async fn load_editor_preferences(
pool: &Database,
user_id: i64,
resource: EditorPreferenceResource,
) -> Result<Option<EditorPreferences>, sqlx::Error> {
let Some(row) = sqlx::query(queries::get(
pool.kind(),
preference_select_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.fetch_optional(pool.pool())
.await?
else {
return Ok(None);
};
Ok(Some(EditorPreferences {
compact_view: row.try_get::<i64, _>(0)? != 0,
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
line_links: row.try_get::<i64, _>(3)? != 0,
font_family: crate::row_decode::text(&row, 4)?,
font_size: row.try_get(5)?,
}))
}
pub async fn save_editor_configuration(
pool: &Database,
user_id: i64,
resource: EditorPreferenceResource,
preferences: Option<&EditorPreferences>,
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
) -> Result<(), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
if let Some(preferences) = preferences {
sqlx::query(queries::get(
pool.kind(),
preference_upsert_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.bind(preferences.compact_view)
.bind(preferences.editor_line_numbers)
.bind(preferences.preview_line_numbers)
.bind(preferences.line_links)
.bind(&preferences.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
}
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_UPSERT,
))
.bind(resource_kind)
.bind(resource_slug)
.bind(&settings.authorship_mode)
.bind(settings.colors_enabled)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn load_resource_editor_settings(
pool: &Database,
resource_kind: &str,
resource_slug: &str,
) -> Result<ResourceEditorSettings, sqlx::Error> {
let Some(row) = sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_SELECT,
))
.bind(resource_kind)
.bind(resource_slug)
.fetch_optional(pool.pool())
.await?
else {
return Ok(ResourceEditorSettings::default());
};
Ok(ResourceEditorSettings {
authorship_mode: crate::row_decode::text(&row, 0)?,
colors_enabled: row.try_get::<i64, _>(1)? != 0,
})
}
pub async fn delete_resource_editor_state(
pool: &Database,
resource_kind: &str,
resource_slug: &str,
) -> Result<(), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_COLORS_DELETE_BY_RESOURCE,
))
.bind(resource_kind)
.bind(resource_slug)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_DELETE,
))
.bind(resource_kind)
.bind(resource_slug)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+2
View File
@@ -18,9 +18,11 @@ use serde::Serialize;
use sqlx::FromRow; use sqlx::FromRow;
use sqlx::{Any, Row, Transaction, any::AnyRow}; use sqlx::{Any, Row, Transaction, any::AnyRow};
mod editor_preferences;
mod files; mod files;
mod public_pages; mod public_pages;
pub use editor_preferences::*;
pub use files::*; pub use files::*;
pub use public_pages::*; pub use public_pages::*;
+1
View File
@@ -11,6 +11,7 @@ mod api;
mod app; mod app;
mod assets; mod assets;
mod auth; mod auth;
mod cache;
mod config; mod config;
mod database; mod database;
mod db; mod db;
+16 -2
View File
@@ -30,9 +30,16 @@ pub enum Query {
RESOURCE_COLOR_BY_USER, RESOURCE_COLOR_BY_USER,
RESOURCE_COLOR_DELETE, RESOURCE_COLOR_DELETE,
RESOURCE_COLOR_INSERT, RESOURCE_COLOR_INSERT,
RESOURCE_COLORS_DELETE_BY_RESOURCE,
RESOURCE_COLORS_DELETE_BY_USER,
RESOURCE_EDITOR_SETTINGS_SELECT, RESOURCE_EDITOR_SETTINGS_SELECT,
RESOURCE_EDITOR_SETTINGS_UPSERT,
RESOURCE_EDITOR_SETTINGS_DELETE, RESOURCE_EDITOR_SETTINGS_DELETE,
RESOURCE_EDITOR_SETTINGS_INSERT, EDITOR_PREFERENCES_SELECT_PAD,
EDITOR_PREFERENCES_SELECT_NOTE,
EDITOR_PREFERENCES_UPSERT_PAD,
EDITOR_PREFERENCES_UPSERT_NOTE,
EDITOR_PREFERENCES_DELETE_BY_USER,
AUTH_ACCOUNT_ACTION_BY_TOKEN, AUTH_ACCOUNT_ACTION_BY_TOKEN,
AUTH_CONSUME_ACCOUNT_ACTION, AUTH_CONSUME_ACCOUNT_ACTION,
AUTH_UPDATE_EMAIL, AUTH_UPDATE_EMAIL,
@@ -173,9 +180,16 @@ pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER; pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE; pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
pub const RESOURCE_COLOR_INSERT: Query = Query::RESOURCE_COLOR_INSERT; pub const RESOURCE_COLOR_INSERT: Query = Query::RESOURCE_COLOR_INSERT;
pub const RESOURCE_COLORS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_COLORS_DELETE_BY_RESOURCE;
pub const RESOURCE_COLORS_DELETE_BY_USER: Query = Query::RESOURCE_COLORS_DELETE_BY_USER;
pub const RESOURCE_EDITOR_SETTINGS_SELECT: Query = Query::RESOURCE_EDITOR_SETTINGS_SELECT; pub const RESOURCE_EDITOR_SETTINGS_SELECT: Query = Query::RESOURCE_EDITOR_SETTINGS_SELECT;
pub const RESOURCE_EDITOR_SETTINGS_UPSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_UPSERT;
pub const RESOURCE_EDITOR_SETTINGS_DELETE: Query = Query::RESOURCE_EDITOR_SETTINGS_DELETE; pub const RESOURCE_EDITOR_SETTINGS_DELETE: Query = Query::RESOURCE_EDITOR_SETTINGS_DELETE;
pub const RESOURCE_EDITOR_SETTINGS_INSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_INSERT; pub const EDITOR_PREFERENCES_SELECT_PAD: Query = Query::EDITOR_PREFERENCES_SELECT_PAD;
pub const EDITOR_PREFERENCES_SELECT_NOTE: Query = Query::EDITOR_PREFERENCES_SELECT_NOTE;
pub const EDITOR_PREFERENCES_UPSERT_PAD: Query = Query::EDITOR_PREFERENCES_UPSERT_PAD;
pub const EDITOR_PREFERENCES_UPSERT_NOTE: Query = Query::EDITOR_PREFERENCES_UPSERT_NOTE;
pub const EDITOR_PREFERENCES_DELETE_BY_USER: Query = Query::EDITOR_PREFERENCES_DELETE_BY_USER;
pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: Query = Query::AUTH_ACCOUNT_ACTION_BY_TOKEN; pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: Query = Query::AUTH_ACCOUNT_ACTION_BY_TOKEN;
pub const AUTH_CONSUME_ACCOUNT_ACTION: Query = Query::AUTH_CONSUME_ACCOUNT_ACTION; pub const AUTH_CONSUME_ACCOUNT_ACTION: Query = Query::AUTH_CONSUME_ACCOUNT_ACTION;
pub const AUTH_UPDATE_EMAIL: Query = Query::AUTH_UPDATE_EMAIL; pub const AUTH_UPDATE_EMAIL: Query = Query::AUTH_UPDATE_EMAIL;
+28 -6
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => { Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => { Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# r#"SELECT CAST(authorship_mode AS CHAR CHARACTER SET utf8mb4), CAST(CASE WHEN colors_enabled THEN 1 ELSE 0 END AS SIGNED) FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE authorship_mode = VALUES(authorship_mode), colors_enabled = VALUES(colors_enabled), updated_at = CURRENT_TIMESTAMP"#
} }
Query::RESOURCE_EDITOR_SETTINGS_DELETE => { Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_EDITOR_SETTINGS_INSERT => { Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
} }
Query::RESOURCE_COLOR_INSERT => { Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
} }
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
} }
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => { Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"# r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"#
} }
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => { Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+27 -5
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => { Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
} }
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = $1 AND resource_slug = $2"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = $1"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => { Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
} }
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = EXCLUDED.authorship_mode, colors_enabled = EXCLUDED.colors_enabled, updated_at = CURRENT_TIMESTAMP"#
}
Query::RESOURCE_EDITOR_SETTINGS_DELETE => { Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
} }
Query::RESOURCE_EDITOR_SETTINGS_INSERT => { Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES ($1, $2, $3, $4)"# r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
} }
Query::RESOURCE_COLOR_INSERT => { Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"# r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
} }
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"# r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
} }
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_USER => { Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = $6 WHERE id = $7"# r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = $6 WHERE id = $7"#
} }
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = $1"#, Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => { Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+27 -5
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => { Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => { Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = excluded.authorship_mode, colors_enabled = excluded.colors_enabled, updated_at = CURRENT_TIMESTAMP"#
}
Query::RESOURCE_EDITOR_SETTINGS_DELETE => { Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_EDITOR_SETTINGS_INSERT => { Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
} }
Query::RESOURCE_COLOR_INSERT => { Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
} }
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
} }
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => { Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"# r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"#
} }
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => { Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+109 -12
View File
@@ -300,6 +300,18 @@ textarea:focus {
min-width: 0; min-width: 0;
} }
.document-heading--copy {
border-radius: 6px;
cursor: copy;
outline: none;
}
.document-heading--copy:hover,
.document-heading--copy:focus-visible {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent);
}
.document-heading h1 { .document-heading h1 {
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
@@ -4532,11 +4544,22 @@ dialog::backdrop {
gap: 5px; gap: 5px;
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
padding: 5px; padding: 5px;
isolation: isolate;
border: 1px solid rgba(255, 255, 255, .12); border: 1px solid rgba(255, 255, 255, .12);
border-radius: 999px; border-radius: 999px;
background: rgba(17, 21, 28, .88); background: transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, .28); box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
}
.pad-page .mobile-editor-bubble::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background: rgba(17, 21, 28, .88);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
content: "";
pointer-events: none;
} }
.mobile-editor-bubble>button, .mobile-editor-bubble>button,
@@ -5059,6 +5082,12 @@ dialog::backdrop {
flex-wrap: wrap; flex-wrap: wrap;
} }
.authorship-controls button:disabled,
.authorship-controls input:disabled+.switch-control__track {
cursor: not-allowed;
opacity: .55;
}
.switch-control { .switch-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -5107,17 +5136,6 @@ dialog::backdrop {
background: var(--accent); background: var(--accent);
} }
.editor-settings-save {
min-height: 22px;
height: 22px;
padding: 0 8px;
font-size: 11px;
}
.editor-settings-save:disabled {
opacity: .45;
cursor: not-allowed;
}
.share-link-row .share-link-info { .share-link-row .share-link-info {
margin-top: 8px; margin-top: 8px;
@@ -5320,6 +5338,7 @@ dialog::backdrop {
width: auto; width: auto;
} }
} }
/* Optional links to exact editor lines. */ /* Optional links to exact editor lines. */
.line-number-button { .line-number-button {
display: block; display: block;
@@ -5357,3 +5376,81 @@ dialog::backdrop {
background: color-mix(in srgb, var(--success) 18%, transparent); background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--text); color: var(--text);
} }
/* Personal editor preferences in the compact mobile action bar. */
@media (max-width: 1499px) {
.mobile-editor-options {
position: relative;
flex: 0 0 auto;
}
.mobile-editor-options>summary {
display: inline-grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .07);
color: var(--text);
cursor: pointer;
list-style: none;
}
.mobile-editor-options>summary::-webkit-details-marker {
display: none;
}
.mobile-editor-options[open]>summary {
background: color-mix(in srgb, var(--accent) 24%, rgba(255, 255, 255, .07));
}
.mobile-editor-options__panel {
position: fixed;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: auto;
display: grid;
width: min(360px, calc(100vw - 24px));
max-height: calc(100dvh - 88px);
gap: 5px;
padding: 12px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-strong, #11161e);
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
}
.mobile-editor-options__panel>label:not(.mobile-option-check) {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
align-items: center;
gap: 10px;
color: var(--muted);
font-size: .78rem;
}
.mobile-editor-options__panel select {
width: 100%;
min-height: 34px;
}
.mobile-option-check {
display: flex;
min-height: 24px;
align-items: center;
gap: 7px;
color: var(--text);
font-size: .8rem;
line-height: 1.2;
}
.mobile-option-check input {
width: 17px;
height: 17px;
margin: 0;
}
}
+40 -18
View File
@@ -5,19 +5,20 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>__PAD_TITLE__ · RustPad</title> <title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
__APP_STYLESHEET__ __APP_STYLESHEET__
__APP_IMPORT_MAP__ __APP_IMPORT_MAP__
__APP_ENTRYPOINT__ __APP_ENTRYPOINT__
</head> </head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"> <body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header"> <header class="app-header">
<div class="app-header__main"><a class="brand home-brand" href="/">RustPad</a><span <div class="app-header__main"><a id="resource-parent-link" class="brand __PARENT_CLASS__"
class="header-divider"></span> href="__PARENT_URL__">__PARENT_TITLE__</a><span class="header-divider"></span>
<div class="document-heading"> <div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
<h1 id="pad-title">__PAD_TITLE__</h1> title="Copy this link" aria-label="Copy this link">
<p id="pad-url" class="document-url"></p> <h1 id="document-title">__DOCUMENT_TITLE__</h1>
<p id="document-url" class="document-url"></p>
</div> </div>
</div> </div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button" <span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
@@ -43,7 +44,8 @@
class="public-task-toggle" class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div> id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><button id="files-button" class="secondary-button">Files</button><button id="history-button" </details><button id="files-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> class="secondary-button">History</button>
</div> </div>
</div> </div>
@@ -119,14 +121,12 @@
<div class="column-label editor-column-label"><span>Editor</span> <div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch" <div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle" title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track" type="checkbox" checked disabled><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label> on</span></label>
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button <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="simple" class="active" disabled>Simple</button><button
type="button" data-authorship-mode="full">Full</button></div><button type="button" data-authorship-mode="full" disabled>Full</button></div>
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
</div> </div>
</div> </div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div> <div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
@@ -193,8 +193,7 @@
<div class="shortcut-grid"> <div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered <kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span><kbd>Alt+Enter</kbd><span>New line while list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>
</div> </div>
</div> </div>
</dialog> </dialog>
@@ -219,9 +218,9 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div> id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden> <section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / LDAP Username<input <h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com or name.second-name"></label><label>Password<input placeholder="you@example.com"></label><label>Password<input
id="auth-password" name="password" type="password" minlength="8" maxlength="128" id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password"></label><button id="auth-submit" class="primary-button" autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
type="submit">Log in and continue</button> type="submit">Log in and continue</button>
@@ -235,7 +234,7 @@
</dialog> </dialog>
<dialog id="password-dialog"> <dialog id="password-dialog">
<form id="password-form" class="dialog-panel"> <form id="password-form" class="dialog-panel">
<h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required <h2>Protected __PROTECTED_RESOURCE_LABEL__</h2><input id="open-password" type="password" autocomplete="current-password" required
placeholder="Password"> placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a <p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
class="dialog-link" href="/">Cancel</a> class="dialog-link" href="/">Cancel</a>
@@ -245,6 +244,29 @@
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions" <button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button> aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button> <button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<details id="mobile-editor-options" class="mobile-editor-options">
<summary title="Editor options" aria-label="Open editor options"></summary>
<div class="mobile-editor-options__panel">
<label>Font<select id="mobile-font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label>
<label>Size<select id="mobile-font-size">
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label>
<label class="mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
<label class="mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
<label class="mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
<label class="mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
</div>
</details>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color" <label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label> id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
+29
View File
@@ -28,6 +28,25 @@ function safeUrl(value) {
} }
const emoji = EMOJI_SHORTCODES; const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), {
url: String(file.url),
mimeType: String(file.mime_type || ""),
}]));
}
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
}
function inline(value) { function inline(value) {
const tokens = []; const tokens = [];
@@ -39,6 +58,16 @@ function inline(value) {
let html = escapeHtml(value); let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`)); html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const file = markdownFiles.get(filename);
if (!file) return match;
const text = String(label || filename).trim() || filename;
if (kind.toLowerCase() === "file") {
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`); return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`);
+2 -2
View File
@@ -21,7 +21,7 @@ export function createPadAdapter() {
return { return {
access: { kind: "pad", key: slug }, access: { kind: "pad", key: slug },
addressSelector: "#pad-url", addressSelector: "#document-url",
title: info => `${info.title} · RustPad`, title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }), loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }), loadColor: headers => api(`${base}/editor-color`, { headers }),
@@ -62,7 +62,7 @@ export function createWorkspaceNoteAdapter() {
return { return {
access: { kind: "workspace", key: workspaceSlug }, access: { kind: "workspace", key: workspaceSlug },
addressSelector: "#note-url", addressSelector: "#document-url",
title: info => `${info.title} · ${info.workspace_title}`, title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }), loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }), loadColor: headers => api(`${base}/editor-color`, { headers }),
+158 -25
View File
@@ -15,7 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files"; import { bindNoteFiles } from "@rustpad/note-files";
@@ -27,24 +27,69 @@ 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"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), 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"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), 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"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
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);
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`; const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
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 = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; 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 = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)");
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"; let refreshFilesForAliases = () => { };
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on"; let aliasRefreshTimer = 0;
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; let lastUnresolvedAliasKey = "";
lineLinksToggle.checked = localStorage.getItem("rustpad:line-links") === "on"; let markdownFileSignature = "";
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono"; function updateMarkdownFiles(files, { rerender = false } = {}) {
fontSize.value = localStorage.getItem("rustpad:font-size") || "14"; const normalized = (Array.isArray(files) ? files : []).map(file => ({
filename: String(file?.filename || ""),
url: String(file?.url || ""),
mime_type: String(file?.mime_type || ""),
})).sort((left, right) => left.filename.localeCompare(right.filename));
const nextSignature = JSON.stringify(normalized);
const changed = nextSignature !== markdownFileSignature;
markdownFileSignature = nextSignature;
setMarkdownFiles(normalized);
if (rerender && changed) render();
}
function scheduleAliasFileRefresh(content) {
const key = unresolvedMarkdownFileAliases(content).sort().join("\u0000");
if (!key) {
lastUnresolvedAliasKey = "";
return;
}
if (key === lastUnresolvedAliasKey) return;
lastUnresolvedAliasKey = key;
clearTimeout(aliasRefreshTimer);
aliasRefreshTimer = window.setTimeout(() => refreshFilesForAliases(), 200);
}
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem(notePreferenceKey("line-numbers")) !== "off";
previewLineToggle.checked = localStorage.getItem(notePreferenceKey("preview-line-numbers")) === "on";
compactToggle.checked = localStorage.getItem(notePreferenceKey("compact")) !== "off";
lineLinksToggle.checked = localStorage.getItem(notePreferenceKey("line-links")) === "on";
fontFamily.value = localStorage.getItem(notePreferenceKey("font-family")) || "mono";
fontSize.value = localStorage.getItem(notePreferenceKey("font-size")) || "14";
authorshipColorsToggle.checked = authorshipColorsEnabled; authorshipColorsToggle.checked = authorshipColorsEnabled;
function syncMobileEditorControls() {
if (mobileFontFamily) mobileFontFamily.value = fontFamily.value;
if (mobileFontSize) mobileFontSize.value = fontSize.value;
if (mobileLineToggle) mobileLineToggle.checked = lineToggle.checked;
if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked;
if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked;
if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked;
}
syncMobileEditorControls();
function updateAuthorshipControls() { function updateAuthorshipControls() {
const canManage = info?.can_manage_authorship === true;
authorshipColorsToggle.checked = authorshipColorsEnabled; authorshipColorsToggle.checked = authorshipColorsEnabled;
authorshipColorsToggle.disabled = !canManage;
authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off"; authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off";
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode)); document.querySelectorAll("[data-authorship-mode]").forEach(button => {
button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode);
button.disabled = !canManage;
});
const controls = document.querySelector(".authorship-controls");
if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings";
} }
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; } function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; } function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
@@ -67,7 +112,13 @@ export function startNoteEditor(adapter) {
}, replacement, contentLength); }, replacement, contentLength);
} }
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); } function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function sessionHeaders() {
const userToken = getAuthToken();
const resourceToken = accessToken || userToken;
const headers = resourceToken ? { Authorization: `Bearer ${resourceToken}` } : {};
if (userToken && userToken !== resourceToken) headers["X-RustPad-User-Token"] = userToken;
return headers;
}
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() { async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders()); info = await adapter.loadInfo(sessionHeaders());
@@ -78,10 +129,19 @@ export function startNoteEditor(adapter) {
} else { } else {
noteColor = readGuestColor(); noteColor = readGuestColor();
} }
updateMarkdownFiles(info.files || []);
if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false;
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === true;
if (["mono", "system", "serif", "arial", "georgia"].includes(info.font_family)) fontFamily.value = info.font_family;
if (["14", "16", "18", "20", "22"].includes(String(info.font_size))) fontSize.value = String(info.font_size);
}
authorshipMode = info.authorship_mode === "full" ? "full" : "simple"; authorshipMode = info.authorship_mode === "full" ? "full" : "simple";
authorshipColorsEnabled = info.colors_enabled !== false; authorshipColorsEnabled = info.colors_enabled !== false;
updateAuthorshipControls(); updateAuthorshipControls();
if (saveEditorSettingsButton) saveEditorSettingsButton.disabled = !info.can_save_editor_settings; syncMobileEditorControls();
updateCurrentUser(); return info; updateCurrentUser(); return info;
} }
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 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(); }
@@ -202,10 +262,17 @@ export function startNoteEditor(adapter) {
if (tag === "code") return { open: "`", close: "`", atomic: null }; if (tag === "code") return { open: "`", close: "`", atomic: null };
if (tag === "sub") return { open: "~", close: "~", atomic: null }; if (tag === "sub") return { open: "~", close: "~", atomic: null };
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null }; if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
const fileAlias = current.getAttribute("data-file-alias");
const fileName = current.getAttribute("data-file-name");
if (tag === "a" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null }; if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") { if (tag === "img") {
const src = current.getAttribute("src") || "";
const alt = current.getAttribute("alt") || ""; const alt = current.getAttribute("alt") || "";
if (fileAlias === "image" && fileName) {
const safeAlt = alt.replace(/\]/g, ")").replace(/[\r\n]+/g, " ");
return { open: "", close: "", atomic: `[image=${fileName},${safeAlt}]` };
}
const src = current.getAttribute("src") || "";
const title = current.getAttribute("title"); const title = current.getAttribute("title");
return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` }; return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` };
} }
@@ -478,7 +545,7 @@ export function startNoteEditor(adapter) {
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0; const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
preview.scrollTop = ratio * previewRange; preview.scrollTop = ratio * previewRange;
} }
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); } function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function activeView() { function activeView() {
return singlePaneQuery.matches ? compactView : uiState.view; return singlePaneQuery.matches ? compactView : uiState.view;
} }
@@ -549,9 +616,11 @@ export function startNoteEditor(adapter) {
const { loadFiles } = bindNoteFiles({ const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files), editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
endpoints: adapter.fileEndpoints, endpoints: adapter.fileEndpoints,
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
}); });
refreshFilesForAliases = () => loadFiles();
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 || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken(); 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() { async function showSystemNotFound() {
try { try {
@@ -579,7 +648,7 @@ export function startNoteEditor(adapter) {
return; return;
} }
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken();
await loadNoteInfo(); await loadNoteInfo();
document.title = adapter.title(info); document.title = adapter.title(info);
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls(); publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
@@ -631,7 +700,24 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => { compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false); if (!event.matches) setHeaderMenuOpen(false);
}); });
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-links", lineLinksToggle.checked ? "on" : "off"); renderGutter(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => {
if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked;
else desktopControl.value = mobileControl.value;
desktopControl.dispatchEvent(new Event("change", { bubbles: true }));
});
mirrorMobileControl(mobileFontFamily, fontFamily);
mirrorMobileControl(mobileFontSize, fontSize);
mirrorMobileControl(mobileLineToggle, lineToggle);
mirrorMobileControl(mobilePreviewLineToggle, previewLineToggle);
mirrorMobileControl(mobileCompactToggle, compactToggle);
mirrorMobileControl(mobileLineLinksToggle, lineLinksToggle);
document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click()); document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click());
const compactBubbleQuery = matchMedia("(max-width: 1499px)"); const compactBubbleQuery = matchMedia("(max-width: 1499px)");
const roomPopover = roomDetails.querySelector(".room-popover"); const roomPopover = roomDetails.querySelector(".room-popover");
@@ -788,27 +874,66 @@ export function startNoteEditor(adapter) {
}); });
} }
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => { document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
if (!info?.can_manage_authorship) return;
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple"; authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
updateAuthorshipControls(); updateAuthorshipControls();
syncMobileEditorControls();
renderGutter(); renderGutter();
scheduleEditorSettingsSave({ authorship: true });
})); }));
authorshipColorsToggle?.addEventListener("change", () => { authorshipColorsToggle?.addEventListener("change", () => {
if (!info?.can_manage_authorship) return;
authorshipColorsEnabled = authorshipColorsToggle.checked; authorshipColorsEnabled = authorshipColorsToggle.checked;
updateAuthorshipControls(); updateAuthorshipControls();
syncMobileEditorControls();
renderGutter(); renderGutter();
scheduleEditorSettingsSave({ authorship: true });
}); });
saveEditorSettingsButton?.addEventListener("click", async () => { function personalEditorSettingsPayload() {
if (!info?.can_save_editor_settings) return; return {
saveEditorSettingsButton.disabled = true; compact_view: compactToggle.checked,
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
}
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
if (personal) pendingPersonalSettingsSave = true;
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
async function flushEditorSettingsSave() {
if (editorSettingsSaveInFlight || !info?.can_save_editor_settings) return;
const savePersonal = pendingPersonalSettingsSave;
const saveAuthorship = pendingAuthorshipSettingsSave && info.can_manage_authorship;
if (!savePersonal && !saveAuthorship) return;
pendingPersonalSettingsSave = false;
pendingAuthorshipSettingsSave = false;
editorSettingsSaveInFlight = true;
const settings = savePersonal ? personalEditorSettingsPayload() : {};
if (saveAuthorship) {
settings.authorship_mode = authorshipMode;
settings.colors_enabled = authorshipColorsEnabled;
}
try { try {
await adapter.saveEditorSettings(sessionHeaders(), { authorship_mode: authorshipMode, colors_enabled: authorshipColorsEnabled }); await adapter.saveEditorSettings(sessionHeaders(), settings);
toast("Editor settings saved for everyone"); if (savePersonal) info.personal_editor_settings = true;
} catch (error) { } catch (error) {
toast(error.message); toast(error.message);
} finally { } finally {
saveEditorSettingsButton.disabled = !info?.can_save_editor_settings; editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
}
} }
});
window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); }); window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); }); window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("rustpad:urlchange", updateAddressLabel); window.addEventListener("rustpad:urlchange", updateAddressLabel);
@@ -826,9 +951,17 @@ export function startNoteEditor(adapter) {
toast(error.message); toast(error.message);
} }
}); });
document.querySelector("#copy-link").addEventListener("click", async () => { async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); } catch (error) { toast(error.message); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
}); });
let pendingPreviewFormatRange = null; let pendingPreviewFormatRange = null;
+19 -7
View File
@@ -31,7 +31,17 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL"); return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
} }
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast }) { function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
return `[${kind}=${filename},${safeLabel}]`;
}
function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog"); const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list"); const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input"); const input = document.querySelector("#file-input");
@@ -50,12 +60,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
</div> </div>
<div class="file-actions"> <div class="file-actions">
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button> <button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button> <button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button> <button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""} ${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
</div> </div>
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div> <div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>'; </div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
onFilesChanged(files);
if (open && !dialog.open) dialog.showModal(); if (open && !dialog.open) dialog.showModal();
} catch (error) { } catch (error) {
if (open) toast(error.message); if (open) toast(error.message);
@@ -95,8 +107,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
}); });
if (completed) return; if (completed) return;
completed = true; completed = true;
const fileUrl = safeAppUrl(result.url); const text = aliasCode(result.name, file.name, result.mime_type || file.type);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true })); editor.dispatchEvent(new Event("input", { bubbles: true }));
uploadToast.success(); uploadToast.success();
@@ -117,8 +128,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
list.addEventListener("click", async event => { list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]"); const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) { if (addButton) {
const relative = safeAppUrl(addButton.dataset.url); const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
const text = addButton.dataset.mime?.startsWith("image/") ? `![${addButton.dataset.name}](${relative})` : `[${addButton.dataset.name}](${relative})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true })); editor.dispatchEvent(new Event("input", { bubbles: true }));
toast("Added to note"); toast("Added to note");
@@ -130,9 +140,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
const output = panel.querySelector("textarea"); const output = panel.querySelector("textarea");
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href; const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
let text = absolute; let text = absolute;
if (showButton.dataset.showFileCode === "markdown") { if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url); const relative = safeAppUrl(showButton.dataset.url);
text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${relative})` : `[${showButton.dataset.name}](${relative})`; text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
} }
output.value = text; panel.hidden = false; output.focus(); output.select(); return; output.value = text; panel.hidden = false; output.focus(); output.select(); return;
} }
+2 -2
View File
@@ -12,7 +12,7 @@ installGlobalDiagnostics();
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast"; import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1]; const token = location.pathname.split("/").filter(Boolean)[1];
@@ -39,7 +39,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)}`, { 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); } } 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`; setMarkdownFiles(page.files || []); 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;
-259
View File
@@ -1,259 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__NOTE_TITLE__ · RustPad</title>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a id="workspace-link" class="brand"
href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="note-title">__NOTE_TITLE__</h1>
<p id="note-url" class="document-url"></p>
</div>
</div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
type="color" aria-label="Override color for this note"><button id="use-global-color"
class="use-global-color" type="button" title="Use global profile color"
aria-label="Use global profile color" hidden>↺</button></span>
<div class="header-navigation">
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
aria-controls="header-actions" aria-label="Open navigation menu">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button>
<details class="page-settings">
<summary class="secondary-button">Page settings</summary>
<div class="page-settings-menu"><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><button id="files-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>
</div>
</header>
<main class="editor-layout">
<section class="editor-panel">
<div class="editor-toolbar">
<div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button
data-format="italic" title="Italic"><em>I</em></button><button data-format="strike"
title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button
data-format="heading2">H2</button><button data-format="heading3">H3</button><button
data-format="heading4">H4</button><button data-format="bullet">• List</button><button
data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button
data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button
data-format="quote">Quote</button><button data-format="link">Link</button>
<details id="emoji-picker" class="emoji-picker">
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
<div class="emoji-picker-panel">
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…"
autocomplete="off" aria-label="Search emoji">
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
</div>
</details>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
section</button><button type="button" data-format="toc">Table of
contents</button><button type="button" data-format="alert-success">Success
alert</button><button type="button" data-format="alert-info">Info
alert</button><button type="button" data-format="alert-warning">Warning
alert</button><button type="button" data-format="alert-danger">Danger
alert</button><button type="button" data-format="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</button><button
type="button" data-format="codeblock-lines">Code block with line
numbers</button><button type="button" data-format="mermaid">Mermaid
diagram</button><button type="button" data-format="table">Table</button><button
type="button" data-format="footnote">Footnote</button><button type="button"
data-format="definition">Definition</button><button type="button"
data-format="highlight">Highlight</button><button type="button"
data-format="subscript">Subscript</button><button type="button"
data-format="superscript">Superscript</button><button type="button"
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</div>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label><label>Size<select id="font-size">
<option value="14" selected>14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label></div><button id="upload-button"
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
Line links</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label>
<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><button
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
</div>
</div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell">
<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"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>
<div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div>
<article id="preview" class="preview markdown-body"></article>
</div>
</div>
<footer class="editor-footer">
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span
id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
<details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
<div class="room-popover">
<section class="room-users"><strong>In this room</strong>
<ul id="room-users"></ul>
</section>
<section class="room-chat">
<div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after
disconnect</span></div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000"
autocomplete="off" placeholder="Write a message…"
aria-label="Chat message"><button type="submit">Send</button></form>
</section>
</div>
</details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <span id="access-level"
class="footer-access">Access: checking…</span> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>
</div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
<h2>What should we call you?</h2>
<p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input
id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
password?</button><button id="auth-back" class="text-button" type="button">Back to
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
account</button></div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</form>
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
required placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
class="dialog-link" href="/">Cancel</a>
</form>
</dialog>
<div id="mobile-editor-bubble" class="mobile-editor-bubble" aria-label="Quick editor actions">
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span></span>
</div>
<div id="toast" class="toast"></div>
</body>
</html>