new finctions and fixes
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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(¬e.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"),
|
||||||
);
|
);
|
||||||
|
|||||||
+261
-85
@@ -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,
|
state,
|
||||||
|
headers,
|
||||||
permission_kind,
|
permission_kind,
|
||||||
permission_slug,
|
permission_slug,
|
||||||
bearer_token(headers),
|
|
||||||
)
|
)
|
||||||
.await
|
.await?
|
||||||
.map_err(|e| ApiError::forbidden(&e.message))?;
|
{
|
||||||
if permission.as_deref() != Some("rw") {
|
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Read and write access is required to save editor settings",
|
"Read and write access is required to save editor preferences",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mode = match payload.authorship_mode.as_str() {
|
let user = session_user(state, headers)
|
||||||
"simple" => "simple",
|
.await?
|
||||||
"full" | "advanced" => "full",
|
.ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?;
|
||||||
_ => return Err(ApiError::bad_request("Invalid authorship mode")),
|
|
||||||
|
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,
|
||||||
|
permission_kind,
|
||||||
|
permission_slug,
|
||||||
|
user_session_token(headers),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
};
|
};
|
||||||
let mut tx = state.db.pool().begin().await?;
|
if wants_global_update && !can_manage_authorship {
|
||||||
sqlx::query(queries::get(
|
return Err(ApiError::forbidden(
|
||||||
state.db.kind(),
|
"Only the resource owner can change authorship settings",
|
||||||
queries::RESOURCE_EDITOR_SETTINGS_DELETE,
|
));
|
||||||
))
|
}
|
||||||
.bind(settings_kind)
|
|
||||||
.bind(settings_slug)
|
let preferences = if wants_personal_update {
|
||||||
.execute(&mut *tx)
|
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")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.colors_enabled {
|
||||||
|
settings.colors_enabled = value;
|
||||||
|
}
|
||||||
|
Some(settings)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
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(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
can_delete_files: {
|
can_delete_files: {
|
||||||
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, ¬e_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
|
||||||
|
|||||||
+69
-19
@@ -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,
|
||||||
.await
|
db::EditorPreferenceResource::Pad(pad.id),
|
||||||
.ok()
|
)
|
||||||
.flatten()
|
.await?;
|
||||||
.as_deref()
|
let resource_editor_settings =
|
||||||
== Some("rw");
|
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
|
||||||
|
.unwrap_or(false);
|
||||||
|
let can_save_editor_settings = personal_editor_settings
|
||||||
|
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||||
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -46,9 +46,10 @@ 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()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
|
|||||||
+52
-42
@@ -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));
|
"pad",
|
||||||
assets::render_html(
|
"pad",
|
||||||
&html,
|
&pad.title,
|
||||||
&state.asset_version,
|
"RustPad",
|
||||||
state.registration_enabled,
|
"/",
|
||||||
state.ldap.is_some(),
|
"home-brand",
|
||||||
&state.frontend_log_level,
|
"note",
|
||||||
state.upload_max_size_bytes,
|
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
|
||||||
"pad",
|
),
|
||||||
)
|
|
||||||
}
|
|
||||||
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, ¬e_slug).await {
|
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||||
Ok(Some(note)) => {
|
Ok(Some(note)) => render_editor_page(
|
||||||
let html = include_str!("../../static/note.html")
|
&state,
|
||||||
.replace(
|
"note",
|
||||||
"__NOTE_TITLE__",
|
"note",
|
||||||
&escape_html(if workspace.is_private != 0 {
|
if workspace.is_private != 0 { "Note" } else { ¬e.title },
|
||||||
"Note"
|
if workspace.is_private != 0 { "Workspace" } else { &workspace.title },
|
||||||
} else {
|
&format!("/w/{workspace_slug}"),
|
||||||
¬e.title
|
"",
|
||||||
}),
|
"workspace",
|
||||||
)
|
"",
|
||||||
.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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
|
|||||||
@@ -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)?
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 H1–H4</span><kbd>Alt+Enter</kbd><span>New line while
|
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</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>
|
||||||
@@ -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}>`);
|
||||||
|
|||||||
@@ -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
@@ -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: `}"` : ""})` };
|
return { open: "", close: "", atomic: `}"` : ""})` };
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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})`;
|
|
||||||
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})`;
|
|
||||||
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})`;
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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 H1–H4</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>
|
|
||||||
Reference in New Issue
Block a user