share invitation

This commit is contained in:
Mateusz Gruszczyński
2026-07-24 10:20:27 +02:00
parent c728f27dcd
commit f52cf91470
19 changed files with 5512 additions and 685 deletions
+3 -1
View File
@@ -64,10 +64,12 @@ MYSQL_USER=rustpad
MYSQL_PASSWORD=rustpad MYSQL_PASSWORD=rustpad
MYSQL_ROOT_PASSWORD=rustpad_root MYSQL_ROOT_PASSWORD=rustpad_root
# Optional account password reset via SMTP # Optional settings
REGISTRATION_ENABLED=false REGISTRATION_ENABLED=false
ACCOUNT_CONFIRMATION_REQUIRED=false ACCOUNT_CONFIRMATION_REQUIRED=false
SHARE_CONFIRMATION_REQUIRED=true
# smtp mailing
PUBLIC_URL=https://pad.example.com PUBLIC_URL=https://pad.example.com
# SMTP_HOST=smtp.example.com # SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
Generated
+1271 -59
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.10" version = "0.0.11"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -0,0 +1,15 @@
CREATE TABLE resource_share_invitations (
token_hash VARCHAR(64) PRIMARY KEY,
resource_kind VARCHAR(16) NOT NULL,
resource_slug VARCHAR(255) NOT NULL,
user_id BIGINT NOT NULL,
permission VARCHAR(2) NOT NULL,
created_by BIGINT NOT NULL,
expires_at TEXT NOT NULL,
accepted_at TEXT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
UNIQUE KEY uq_resource_share_invitation (resource_kind, resource_slug, user_id),
CONSTRAINT fk_share_invitation_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_share_invitation_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
@@ -0,0 +1,13 @@
CREATE TABLE resource_share_invitations (
token_hash TEXT PRIMARY KEY,
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK(permission IN ('ro','rw')),
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
UNIQUE(resource_kind, resource_slug, user_id)
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
@@ -0,0 +1,13 @@
CREATE TABLE resource_share_invitations (
token_hash TEXT PRIMARY KEY,
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK(permission IN ('ro','rw')),
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_kind, resource_slug, user_id)
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
+1
View File
@@ -52,6 +52,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/auth/resources/privacy", post(auth::set_resource_privacy)) .route("/api/auth/resources/privacy", post(auth::set_resource_privacy))
.route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user)) .route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user))
.route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link)) .route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link))
.route("/share-invitations/{token}/accept", get(auth::accept_share_invitation))
.route("/api/auth/password-reset", post(auth::request_reset)) .route("/api/auth/password-reset", post(auth::request_reset))
.route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) .route("/api/auth/password-reset/confirm", post(auth::confirm_reset))
.route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}", get(api::public_page))
+82 -5
View File
@@ -1,5 +1,5 @@
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json}; use axum::{extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, Json};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use lettre::{ use lettre::{
message::{header::ContentType, Mailbox, MultiPart, SinglePart}, message::{header::ContentType, Mailbox, MultiPart, SinglePart},
@@ -226,6 +226,9 @@ pub async fn share_resource_users(State(state): State<SharedState>, headers: Hea
let owner = require_user(&state, &headers).await?; let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
let permission = validate_permission(&req.permission)?; let permission = validate_permission(&req.permission)?;
if state.share_confirmation_required && state.smtp.is_none() {
return Err(AuthError::service_unavailable("Share confirmation requires SMTP configuration."));
}
let emails: Vec<String> = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect(); let emails: Vec<String> = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect();
if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); } if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); }
let mut missing = Vec::new(); let mut missing = Vec::new();
@@ -233,13 +236,52 @@ pub async fn share_resource_users(State(state): State<SharedState>, headers: Hea
let user = find_user_by_email(&state, &email).await?; let user = find_user_by_email(&state, &email).await?;
let Some(user) = user else { missing.push(email); continue; }; let Some(user) = user else { missing.push(email); continue; };
if user.id == owner.id { continue; } if user.id == owner.id { continue; }
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
if state.share_confirmation_required {
let token = random_token();
let token_hash = hash_token(&token);
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"))
.bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at)
.execute(state.db.pool()).await.map_err(AuthError::database)?;
if let Err(error) = send_share_invitation(state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token).await {
let _ = sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE token_hash = ?"))
.bind(&token_hash).execute(state.db.pool()).await;
return Err(error);
}
} else {
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
}
} }
if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); } if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); }
Ok(Json(serde_json::json!({"ok":true}))) Ok(Json(serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required})))
}
pub async fn accept_share_invitation(State(state): State<SharedState>, AxumPath(token): AxumPath<String>) -> Result<Redirect, AuthError> {
let token_hash = hash_token(token.trim());
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"))
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| AuthError::bad_request("The sharing invitation is invalid or has expired."))?;
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))?.with_timezone(&Utc);
if accepted_at.is_none() {
if expires <= Utc::now() { return Err(AuthError::bad_request("The sharing invitation is invalid or has expired.")); }
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
.bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"))
.bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"))
.bind(Utc::now().to_rfc3339()).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
tx.commit().await.map_err(AuthError::database)?;
}
let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") };
Ok(Redirect::to(&target))
} }
pub async fn remove_resource_user(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, AuthError> { pub async fn remove_resource_user(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, AuthError> {
@@ -249,6 +291,8 @@ pub async fn remove_resource_user(State(state): State<SharedState>, headers: Hea
if let Some(user) = find_user_by_email(&state, &email).await? { if let Some(user) = find_user_by_email(&state, &email).await? {
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
} }
Ok(Json(serde_json::json!({"ok":true}))) Ok(Json(serde_json::json!({"ok":true})))
} }
@@ -262,7 +306,9 @@ pub async fn resource_sharing(State(state): State<SharedState>, headers: HeaderM
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"))
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() }))) let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"))
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() })))
} }
pub async fn create_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<CreateShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> { pub async fn create_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<CreateShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> {
@@ -609,6 +655,37 @@ async fn send_registration_email(
send_message(smtp, message, "registration e-mail").await send_message(smtp, message, "registration e-mail").await
} }
async fn send_share_invitation(
smtp: &SmtpConfig,
owner: &User,
recipient_user: &User,
kind: &str,
slug: &str,
permission: &str,
token: &str,
) -> Result<(), AuthError> {
let site = smtp.public_url.trim_end_matches('/');
let accept_url = format!("{site}/share-invitations/{token}/accept");
let resource_label = if kind == "workspace" { "workspace" } else { "note" };
let access_label = if permission == "rw" { "view and edit" } else { "view" };
let sender = smtp.from.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
let recipient = recipient_user.email.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid recipient address."))?;
let subject = format!("{} shared a RustPad {} with you", owner.nickname, resource_label);
let text_body = format!(
"Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.",
recipient_user.nickname, owner.nickname, resource_label, slug, accept_url
);
let html_body = format!(r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">A RustPad {resource_label} was shared with you</h1><p>Hello {},</p><p><strong>{}</strong> shared <strong>{}</strong> with you. Permission: <strong>{access_label}</strong>.</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Accept invitation</a></p><p style="font-size:13px;color:#52525b">This link expires in 7 days.</p></div></body></html>"#,
recipient_user.nickname, owner.nickname, slug, accept_url
);
let message = Message::builder().from(sender).to(recipient).subject(subject)
.multipart(MultiPart::alternative()
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text_body))
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html_body)))
.map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?;
send_message(smtp, message, "sharing invitation e-mail").await
}
async fn send_message( async fn send_message(
smtp: &SmtpConfig, smtp: &SmtpConfig,
message: Message, message: Message,
+2
View File
@@ -16,6 +16,7 @@ pub struct Config {
pub smtp: Option<crate::state::SmtpConfig>, pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
@@ -82,6 +83,7 @@ impl Config {
smtp, smtp,
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
share_confirmation_required: env_bool("SHARE_CONFIRMATION_REQUIRED", false)?,
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days, anonymous_access_token_ttl_days,
user_session_ttl_days, user_session_ttl_days,
+2
View File
@@ -37,6 +37,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
file_cache_max_age_seconds = config.file_cache_max_age_seconds, file_cache_max_age_seconds = config.file_cache_max_age_seconds,
registration_enabled = config.registration_enabled, registration_enabled = config.registration_enabled,
account_confirmation_required = config.account_confirmation_required, account_confirmation_required = config.account_confirmation_required,
share_confirmation_required = config.share_confirmation_required,
frontend_log_level = %config.frontend_log_level, frontend_log_level = %config.frontend_log_level,
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days, anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
user_session_ttl_days = config.user_session_ttl_days, user_session_ttl_days = config.user_session_ttl_days,
@@ -64,6 +65,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.smtp.clone(), config.smtp.clone(),
config.registration_enabled, config.registration_enabled,
config.account_confirmation_required, config.account_confirmation_required,
config.share_confirmation_required,
config.frontend_log_level.clone(), config.frontend_log_level.clone(),
config.anonymous_access_token_ttl_days, config.anonymous_access_token_ttl_days,
config.user_session_ttl_days, config.user_session_ttl_days,
+3 -2
View File
@@ -42,6 +42,7 @@ pub struct AppState {
pub smtp: Option<SmtpConfig>, pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
@@ -51,8 +52,8 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, share_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self {
Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, share_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) }
} }
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> { async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
+3618 -584
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<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">
@@ -8,6 +9,7 @@
<title>__ERROR_TITLE__ · RustPad</title> <title>__ERROR_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
</head> </head>
<body> <body>
<main class="error-page"> <main class="error-page">
<section class="error-card" aria-labelledby="error-title"> <section class="error-card" aria-labelledby="error-title">
@@ -21,4 +23,5 @@
</section> </section>
</main> </main>
</body> </body>
</html> </html>
+31 -13
View File
@@ -1,13 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<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>RustPad</title> <title>RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script> <script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__"> <body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header> <header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header>
<main class="home-layout home-layout--wide"> <main class="home-layout home-layout--wide">
@@ -26,11 +30,15 @@
<div class="field"> <div class="field">
<label for="pad-name">Note name</label> <label for="pad-name">Note name</label>
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes"> <input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes">
<div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span id="pad-name-count">0/80</span></div> <div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span
id="pad-name-count">0/80</span></div>
</div> </div>
<div class="field"> <div class="field">
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span></div> <div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span>
<div class="password-input"><input id="pad-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Note password"><button class="text-button password-toggle" type="button" data-target="pad-password">Show</button></div> </div>
<div class="password-input"><input id="pad-password" type="password" maxlength="128"
autocomplete="new-password" placeholder="Note password"><button class="text-button password-toggle"
type="button" data-target="pad-password">Show</button></div>
</div> </div>
<p id="pad-error" class="form-message error" role="alert"></p> <p id="pad-error" class="form-message error" role="alert"></p>
<button id="pad-button" class="primary-button" type="submit">Create note</button> <button id="pad-button" class="primary-button" type="submit">Create note</button>
@@ -46,11 +54,15 @@
<div class="field"> <div class="field">
<label for="workspace-name">Workspace name</label> <label for="workspace-name">Workspace name</label>
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project"> <input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project">
<div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span id="workspace-name-count">0/80</span></div> <div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span
id="workspace-name-count">0/80</span></div>
</div> </div>
<div class="field"> <div class="field">
<div class="label-row"><label for="workspace-password">Password</label><span>optional, min. 8 characters</span></div> <div class="label-row"><label for="workspace-password">Password</label><span>optional, min. 8
<div class="password-input"><input id="workspace-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Workspace password"><button class="text-button password-toggle" type="button" data-target="workspace-password">Show</button></div> characters</span></div>
<div class="password-input"><input id="workspace-password" type="password" maxlength="128"
autocomplete="new-password" placeholder="Workspace password"><button class="text-button password-toggle"
type="button" data-target="workspace-password">Show</button></div>
</div> </div>
<p id="workspace-error" class="form-message error" role="alert"></p> <p id="workspace-error" class="form-message error" role="alert"></p>
<button id="workspace-button" class="primary-button" type="submit">Create workspace</button> <button id="workspace-button" class="primary-button" type="submit">Create workspace</button>
@@ -63,14 +75,16 @@
<div class="home-footer__inner"> <div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account"> <div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button> <button id="footer-login" class="footer-action" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register nickname</button> <button id="footer-register" class="footer-action footer-action--primary" type="button">Register
nickname</button>
</div> </div>
<div id="footer-account-user" class="home-footer__account" hidden> <div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"></span> <span id="footer-user-label" class="home-footer__user"></span>
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button> <button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
<button id="footer-logout" class="footer-action" type="button">Log out</button> <button id="footer-logout" class="footer-action" type="button">Log out</button>
</div> </div>
<span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></span> <span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl"
rel="author noopener">@linuxiarz.pl</a></span>
</div> </div>
</footer> </footer>
@@ -82,9 +96,12 @@
<p id="identity-copy" class="dialog-copy"></p> <p id="identity-copy" class="dialog-copy"></p>
</header> </header>
<div class="identity-fields"> <div class="identity-fields">
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label> <label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label> placeholder="Your nickname"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label> <label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320"
autocomplete="username" required placeholder="you@example.com"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password" required placeholder="At least 8 characters"></label>
</div> </div>
<button id="auth-submit" class="primary-button" type="submit">Log in</button> <button id="auth-submit" class="primary-button" type="submit">Log in</button>
<div class="identity-links"> <div class="identity-links">
@@ -95,7 +112,7 @@
<p id="identity-error" class="form-message" role="status"></p> <p id="identity-error" class="form-message" role="status"></p>
</form> </form>
</dialog> </dialog>
<dialog id="resources-dialog" class="app-dialog"> <dialog id="resources-dialog" class="app-dialog">
<div class="dialog-panel resources-panel"> <div class="dialog-panel resources-panel">
<button id="close-resources" class="modal-close" type="button" aria-label="Close dialog">×</button> <button id="close-resources" class="modal-close" type="button" aria-label="Close dialog">×</button>
<header class="resources-panel__header"> <header class="resources-panel__header">
@@ -107,4 +124,5 @@
</div> </div>
</dialog> </dialog>
</body> </body>
</html> </html>
+1 -1
View File
@@ -280,7 +280,7 @@ export async function logoutCurrentSession() {
if (token) { if (token) {
try { try {
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } }); await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch {} } catch { }
} }
clearAuthSession(); clearAuthSession();
} }
+192 -5
View File
@@ -1,5 +1,192 @@
<!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><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<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><div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header> <html lang="en">
<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 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="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</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> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</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</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" 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-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</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> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><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<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> <head>
<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 id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <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>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script>
</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>
<div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></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 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="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</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> Lines</label><label
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</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</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" 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-status status"><span id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
title="WebSocket round-trip time">— ms</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> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity"
class="modal-close" type="button" aria-label="Close dialog">×</button>
<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<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
id="back-workspace" class="dialog-link" href="/">Back</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>
+191 -5
View File
@@ -1,5 +1,191 @@
<!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>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header> <html lang="en">
<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 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="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</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> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</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</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" 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-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</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> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><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<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> <head>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</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="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__PAD_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script>
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="pad-title">__PAD_TITLE__</h1>
<p id="pad-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><span class="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="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></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 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="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</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> Lines</label><label
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</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</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" 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-status status"><span id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
title="WebSocket round-trip time">— ms</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> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity"
class="modal-close" type="button" aria-label="Close dialog">×</button>
<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<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 note</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="/">Back</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>
+6 -1
View File
@@ -1,13 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<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>Published note · RustPad</title> <title>Published note · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/public.js?v=__ASSET_VERSION__"></script> <script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/public.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body class="public-page"> <body class="public-page">
<header class="public-header"> <header class="public-header">
<a class="brand" href="/">RustPad</a> <a class="brand" href="/">RustPad</a>
@@ -20,4 +24,5 @@
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
</body> </body>
</html> </html>
+61 -5
View File
@@ -1,5 +1,61 @@
<!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>__WORKSPACE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/workspace.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<body><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="workspace-title">__WORKSPACE_TITLE__</h1><p id="workspace-url" class="document-url"></p></div></div><div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div></header> <html lang="en">
<main class="workspace-page"><section class="workspace-top"><div><h2>Notes</h2><p>Select a note or create a new one.</p></div><button id="new-note-button" class="primary-button inline-button">New note</button></section><div class="notes-toolbar"><span class="notes-toolbar__label">View</span><div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button" data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button" data-notes-view="table" aria-pressed="false">Table</button></div></div><p id="workspace-error" class="form-message error"></p><section id="notes-list" class="notes-grid" aria-live="polite"></section></main>
<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 href="/" class="dialog-link">Cancel</a></form></dialog> <head>
<dialog id="note-dialog"><form id="note-form" class="dialog-panel"><h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label class="dialog-check"><input id="note-protect" type="checkbox" checked> Protect this note from deletion</label><p id="note-error" class="form-message error"></p><div class="dialog-actions"><button type="button" id="cancel-note" class="secondary-button">Cancel</button><button class="primary-button">Create</button></div></form></dialog><div id="toast" class="toast"></div></body></html> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__WORKSPACE_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/workspace.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<header class="app-header">
<div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="workspace-title">__WORKSPACE_TITLE__</h1>
<p id="workspace-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
</header>
<main class="workspace-page">
<section class="workspace-top">
<div>
<h2>Notes</h2>
<p>Select a note or create a new one.</p>
</div><button id="new-note-button" class="primary-button inline-button">New note</button>
</section>
<div class="notes-toolbar"><span class="notes-toolbar__label">View</span>
<div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button"
data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button"
data-notes-view="table" aria-pressed="false">Table</button></div>
</div>
<p id="workspace-error" class="form-message error"></p>
<section id="notes-list" class="notes-grid" aria-live="polite"></section>
</main>
<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
href="/" class="dialog-link">Cancel</a>
</form>
</dialog>
<dialog id="note-dialog">
<form id="note-form" class="dialog-panel">
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
class="dialog-check"><input id="note-protect" type="checkbox" checked> Protect this note from
deletion</label>
<p id="note-error" class="form-message error"></p>
<div class="dialog-actions"><button type="button" id="cancel-note"
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>