From 4b25085bb5c3aac78e4c89ab928865bd887cd85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 4 Aug 2026 23:21:27 +0200 Subject: [PATCH] fixes and functions --- .env.example | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 11 +- src/api/files.rs | 27 ++-- src/api/mod.rs | 33 ++++- src/api/pads_public.rs | 6 +- src/app/mod.rs | 6 +- src/assets.rs | 2 +- src/config/mod.rs | 81 ++++++++++-- src/config/values.rs | 2 + src/main.rs | 6 +- src/state.rs | 6 + static/css/styles.css | 259 +++++++++++-------------------------- static/editor.html | 34 ++--- static/js/api.js | 8 +- static/js/authorship.js | 19 --- static/js/collaboration.js | 25 ---- static/js/home.js | 2 +- static/js/image-alias.js | 20 --- static/js/note-editor.js | 29 ++++- static/js/note-files.js | 14 +- static/js/security.js | 3 - static/js/session.js | 4 - static/js/theme.js | 18 ++- static/js/url-state.js | 7 +- static/js/workspace.js | 6 +- systemd/rustpad.yaml | 2 + 28 files changed, 306 insertions(+), 330 deletions(-) diff --git a/.env.example b/.env.example index ab2b3d7..4b1a8ac 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ RUST_LOG=rustpad=info,tower_http=warn # Maximum upload size UPLOAD_MAX_SIZE_MB=20 +GUEST_UPLOAD_ENABLED=fakse +GUEST_UPLOAD_MAX_SIZE_MB=5 # Attachment storage: local or s3 STORAGE_DRIVER=local diff --git a/Cargo.lock b/Cargo.lock index 085bb4a..9ce3262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.34" +version = "0.2.35" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index b95bea2..9ee147c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.34" +version = "0.2.35" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index 369846f..430f5d6 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,20 @@ Publishing a protected note requires its password, but the generated public page ## Upload limit -Configure the maximum size of a single uploaded file with `UPLOAD_MAX_SIZE_MB` in `.env`, for example: +Configure the maximum size of a single uploaded file for signed-in users with `UPLOAD_MAX_SIZE_MB` in `.env`, for example: ```env UPLOAD_MAX_SIZE_MB=50 ``` -The default limit is 20 MB. Restart the project with `./dev.sh` after changing it. +The default limit is 20 MB. Uploads by guests are disabled by default. Enable them deliberately and set their separate per-file limit with: + +```env +GUEST_UPLOAD_ENABLED=true +GUEST_UPLOAD_MAX_SIZE_MB=5 +``` + +Guest uploads still require read-write access to the note or workspace. Restart the project with `./dev.sh` after changing these values. ## Database selection diff --git a/src/api/files.rs b/src/api/files.rs index 74534d4..47e41ce 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -16,7 +16,7 @@ pub async fn upload_pad_file( Path(slug): Path, mut multipart: Multipart, ) -> Result, ApiError> { - require_upload_permission(&state, &headers).await?; + let upload_max_size_bytes = require_upload_permission(&state, &headers).await?; let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; @@ -46,8 +46,8 @@ pub async fn upload_pad_file( .bytes() .await .map_err(|_| ApiError::bad_request("Failed to read the file"))?; - if bytes.len() > state.upload_max_size_bytes { - return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); + if bytes.len() > upload_max_size_bytes { + return Err(ApiError::payload_too_large(upload_max_size_bytes)); } file = Some((filename, bytes.to_vec())); } @@ -255,7 +255,7 @@ pub async fn upload_note_file( Path((workspace_slug, note_slug)): Path<(String, String)>, mut multipart: Multipart, ) -> Result, ApiError> { - require_upload_permission(&state, &headers).await?; + let upload_max_size_bytes = require_upload_permission(&state, &headers).await?; let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; @@ -285,8 +285,8 @@ pub async fn upload_note_file( .bytes() .await .map_err(|_| ApiError::bad_request("Failed to read the file"))?; - if bytes.len() > state.upload_max_size_bytes { - return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); + if bytes.len() > upload_max_size_bytes { + return Err(ApiError::payload_too_large(upload_max_size_bytes)); } file = Some((filename, bytes.to_vec())); } @@ -507,17 +507,10 @@ pub async fn delete_note_file( async fn require_upload_permission( state: &SharedState, headers: &HeaderMap, -) -> Result<(), ApiError> { - let user = crate::auth::optional_user(state, headers) - .await - .map_err(|error| ApiError::forbidden(&error.message))?; - if user.is_some() { - Ok(()) - } else { - Err(ApiError::forbidden( - "Log in with read-write access to upload files.", - )) - } +) -> Result { + upload_limit_for_request(state, headers) + .await? + .ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests.")) } pub async fn download_file( diff --git a/src/api/mod.rs b/src/api/mod.rs index aa3aff5..22f120c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -173,6 +173,32 @@ async fn has_write_permission( } } +async fn upload_limit_for_request( + state: &SharedState, + headers: &HeaderMap, +) -> Result, ApiError> { + if session_user(state, headers).await?.is_some() { + return Ok(Some(state.upload_max_size_bytes)); + } + Ok(state + .guest_upload_enabled + .then_some(state.guest_upload_max_size_bytes)) +} + +async fn resource_upload_limit( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result, ApiError> { + let Some(limit) = upload_limit_for_request(state, headers).await? else { + return Ok(None); + }; + Ok(has_write_permission(state, headers, kind, slug) + .await? + .then_some(limit)) +} + #[derive(Debug, Serialize)] pub struct PublishResponse { url: Option, @@ -380,6 +406,7 @@ pub struct NoteInfo { updated_at: String, can_delete_files: bool, can_upload_files: bool, + upload_max_size_bytes: Option, global_color: Option, note_color: Option, authorship_mode: String, @@ -996,8 +1023,9 @@ pub async fn note_info( has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; let can_manage_authorship = workspace_owner || note_owner || password_write_access; let can_delete_files = can_manage_authorship; - let can_upload_files = session_user(&state, &headers).await?.is_some() - && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; + let upload_max_size_bytes = + resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?; + let can_upload_files = upload_max_size_bytes.is_some(); let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; @@ -1024,6 +1052,7 @@ pub async fn note_info( updated_at: db::normalize_timestamp(¬e.updated_at), can_delete_files, can_upload_files, + upload_max_size_bytes, global_color, note_color, authorship_mode: resource_editor_settings.authorship_mode, diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 5d7f3ec..ce84a04 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -43,6 +43,7 @@ pub struct PadInfo { updated_at: String, can_delete_files: bool, can_upload_files: bool, + upload_max_size_bytes: Option, global_color: Option, note_color: Option, authorship_mode: String, @@ -135,8 +136,8 @@ pub async fn pad_info( let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; let can_manage_authorship = account_owner || guest_owner || password_write_access; - let can_upload_files = session_user(&state, &headers).await?.is_some() - && has_write_permission(&state, &headers, "pad", &slug).await?; + let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?; + let can_upload_files = upload_max_size_bytes.is_some(); let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "pad", &slug).await?; if pad.is_private == 0 @@ -159,6 +160,7 @@ pub async fn pad_info( updated_at: db::normalize_timestamp(&pad.updated_at), can_delete_files: can_manage_authorship, can_upload_files, + upload_max_size_bytes, global_color, note_color, authorship_mode: resource_editor_settings.authorship_mode, diff --git a/src/app/mod.rs b/src/app/mod.rs index eb73b48..983c451 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -46,7 +46,7 @@ impl MakeSpan for PathOnlyMakeSpan { pub fn router( state: SharedState, static_dir: &str, - upload_max_size_bytes: usize, + upload_body_limit_bytes: usize, asset_cache_max_age_seconds: u64, ) -> Router { let asset_version = state.asset_version.clone(); @@ -229,9 +229,7 @@ pub fn router( ) .fallback(not_found) .method_not_allowed_fallback(method_not_allowed) - .layer(DefaultBodyLimit::max( - upload_max_size_bytes.saturating_add(1024 * 1024), - )) + .layer(DefaultBodyLimit::max(upload_body_limit_bytes)) .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(apply_response_header_policy)) diff --git a/src/assets.rs b/src/assets.rs index b9e295f..d90ca75 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -81,7 +81,7 @@ pub fn render_html( } pub fn theme_bootstrap() -> &'static str { - r#""# + r#""# } pub fn stylesheet_tag(asset_version: &str, name: &str) -> String { diff --git a/src/config/mod.rs b/src/config/mod.rs index 83a2240..55eb808 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -55,6 +55,8 @@ pub struct Config { pub files_dir: String, pub storage: crate::storage::StorageConfig, pub upload_max_size_bytes: usize, + pub guest_upload_enabled: bool, + pub guest_upload_max_size_bytes: usize, pub asset_version: String, pub asset_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64, @@ -77,7 +79,9 @@ impl Config { let host = values.get("APP_HOST", "127.0.0.1").parse()?; let port = values.get("APP_PORT", "3000").parse()?; let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?; - let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?; + let upload_max_size_mb = values.positive_u64("UPLOAD_MAX_SIZE_MB", 20)?; + let guest_upload_enabled = values.bool("GUEST_UPLOAD_ENABLED", false)?; + let guest_upload_max_size_mb = values.positive_u64("GUEST_UPLOAD_MAX_SIZE_MB", 5)?; let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?; @@ -107,10 +111,6 @@ impl Config { _ => return Err("STORAGE_DRIVER must be local or s3".into()), }; - if upload_max_size_mb == 0 { - return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); - } - let authorization_type = AuthorizationType::from_values(&values)?; let ldap = match authorization_type { AuthorizationType::Local => None, @@ -167,9 +167,12 @@ impl Config { static_dir: values.get("STATIC_DIR", "static"), files_dir, storage, - upload_max_size_bytes: upload_max_size_mb - .checked_mul(1024 * 1024) - .ok_or("UPLOAD_MAX_SIZE_MB is too large")?, + upload_max_size_bytes: megabytes_to_bytes("UPLOAD_MAX_SIZE_MB", upload_max_size_mb)?, + guest_upload_enabled, + guest_upload_max_size_bytes: megabytes_to_bytes( + "GUEST_UPLOAD_MAX_SIZE_MB", + guest_upload_max_size_mb, + )?, asset_version: env!("CARGO_PKG_VERSION").to_owned(), asset_cache_max_age_seconds: values .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, @@ -213,4 +216,66 @@ impl Config { } Ok(()) } + + pub fn upload_body_limit_bytes(&self) -> usize { + multipart_body_limit_bytes( + self.upload_max_size_bytes, + self.guest_upload_enabled, + self.guest_upload_max_size_bytes, + ) + } +} + +fn multipart_body_limit_bytes( + user_limit_bytes: usize, + guest_upload_enabled: bool, + guest_limit_bytes: usize, +) -> usize { + let file_limit = if guest_upload_enabled { + user_limit_bytes.max(guest_limit_bytes) + } else { + user_limit_bytes + }; + file_limit.saturating_add(1024 * 1024) +} + +fn megabytes_to_bytes( + name: &str, + megabytes: u64, +) -> Result> { + let bytes = megabytes + .checked_mul(1024 * 1024) + .ok_or_else(|| format!("{name} is too large"))?; + usize::try_from(bytes).map_err(|_| format!("{name} is too large").into()) +} + +#[cfg(test)] +mod tests { + use super::{megabytes_to_bytes, multipart_body_limit_bytes}; + + #[test] + fn converts_upload_megabytes_to_bytes() { + assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024); + } + + #[test] + fn rejects_overflowing_upload_limit() { + assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err()); + } + + #[test] + fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() { + assert_eq!( + multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024), + 21 * 1024 * 1024 + ); + } + + #[test] + fn multipart_limit_uses_larger_enabled_guest_limit() { + assert_eq!( + multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024), + 51 * 1024 * 1024 + ); + } } diff --git a/src/config/values.rs b/src/config/values.rs index c1c18ba..d56f9ab 100644 --- a/src/config/values.rs +++ b/src/config/values.rs @@ -19,6 +19,8 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[ "FILES_PUBLIC_URL", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", + "GUEST_UPLOAD_ENABLED", + "GUEST_UPLOAD_MAX_SIZE_MB", "ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS", "REGISTRATION_ENABLED", diff --git a/src/main.rs b/src/main.rs index d7e7d7e..6d9520e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,8 @@ async fn main() -> Result<(), Box> { files_dir = %config.files_dir, storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" }, upload_max_size_bytes = config.upload_max_size_bytes, + guest_upload_enabled = config.guest_upload_enabled, + guest_upload_max_size_bytes = config.guest_upload_max_size_bytes, asset_cache_max_age_seconds = config.asset_cache_max_age_seconds, file_cache_max_age_seconds = config.file_cache_max_age_seconds, files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"), @@ -106,6 +108,8 @@ async fn main() -> Result<(), Box> { config.asset_version.clone(), storage, config.upload_max_size_bytes, + config.guest_upload_enabled, + config.guest_upload_max_size_bytes, config.file_cache_max_age_seconds, config.files_public_url.clone(), config.smtp.clone(), @@ -166,7 +170,7 @@ async fn main() -> Result<(), Box> { let app = app::router( state, &config.static_dir, - config.upload_max_size_bytes, + config.upload_body_limit_bytes(), config.asset_cache_max_age_seconds, ); let address = SocketAddr::new(config.host, config.port); diff --git a/src/state.rs b/src/state.rs index f3f2d34..1200bf2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -99,6 +99,8 @@ pub struct AppState { pub asset_version: String, pub storage: crate::storage::Storage, pub upload_max_size_bytes: usize, + pub guest_upload_enabled: bool, + pub guest_upload_max_size_bytes: usize, pub file_cache_max_age_seconds: u64, pub files_public_url: Option, pub smtp: Option, @@ -123,6 +125,8 @@ impl AppState { asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, + guest_upload_enabled: bool, + guest_upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, files_public_url: Option, smtp: Option, @@ -140,6 +144,8 @@ impl AppState { asset_version, storage, upload_max_size_bytes, + guest_upload_enabled, + guest_upload_max_size_bytes, file_cache_max_age_seconds, files_public_url, smtp, diff --git a/static/css/styles.css b/static/css/styles.css index 1d80605..bdd9143 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -992,11 +992,6 @@ textarea::selection { font-size: .72rem; } -.document-stats { - display: flex; - gap: 14px; -} - .history-panel { position: relative; width: 340px; @@ -1019,15 +1014,6 @@ textarea::selection { padding: 18px; } -.history-help { - margin: 0; - padding: 0 18px 16px; - border-bottom: 1px solid var(--border); - color: var(--muted); - font-size: .8rem; - line-height: 1.5; -} - .icon-button { display: grid; place-items: center; @@ -1199,10 +1185,6 @@ dialog::backdrop { height: calc(100vh - 92px); } - .toolbar-settings { - display: none; - } - .history-open .editor-layout { grid-template-columns: minmax(0, 1fr); } @@ -1434,7 +1416,6 @@ dialog::backdrop { } .line-gutter { - overflow: hidden; padding: 24px 8px 24px 0; border-right: 1px solid var(--border); @@ -1446,7 +1427,6 @@ dialog::backdrop { .line-gutter div { height: 1.72em; - } .hide-editor-line-numbers .editor-shell { @@ -1722,10 +1702,6 @@ dialog::backdrop { flex-wrap: wrap; } -.home-footer__separator { - color: var(--border-strong); -} - .home-footer .text-button { font-size: inherit; text-decoration: underline; @@ -1761,35 +1737,6 @@ dialog::backdrop { } } -.line-owner-label { - position: absolute; - left: 7px; - top: 50%; - max-width: 88px; - overflow: hidden; - padding: 2px 6px; - border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset)); - color: var(--text-on-owner); - font: 600 10px/1.2 system-ui, sans-serif; - text-overflow: ellipsis; - white-space: nowrap; - transform: translateY(-50%); -} - -@media (min-width: 721px) { - .line-gutter { - width: 132px; - } -} - -@media (max-width: 720px) { - .line-owner-label { - display: none; - } -} - /* Compact line numbers; author labels sit over the edited text, not in the gutter. */ .line-gutter { width: 48px; @@ -1801,10 +1748,8 @@ dialog::backdrop { .owner-labels { position: absolute; - right: 12px; left: 48px; - overflow: hidden; pointer-events: none; } @@ -1831,16 +1776,6 @@ dialog::backdrop { left: 0; } -.line-owner-label { - display: none !important; -} - -@media (min-width: 721px) { - .line-gutter { - width: 48px; - } -} - @media (max-width: 720px) { .line-gutter { width: 42px; @@ -1864,15 +1799,6 @@ dialog::backdrop { height: auto; } -.owner-line { - position: absolute; - right: 0; - left: 0; - height: var(--editor-line-height, 31px); - border-left: 3px solid var(--owner); - background: transparent; -} - .owner-label { z-index: 1; transform: translateY(2px); @@ -2467,16 +2393,6 @@ dialog::backdrop { min-width: 74px; } -.file-delete { - border-color: var(--danger-border) !important; - background: var(--danger-subtle-bg) !important; - color: var(--danger-button-text) !important; -} - -.file-delete:hover { - background: var(--danger-subtle-hover) !important; -} - @media (max-width: 760px) { .file-actions { flex-wrap: wrap; @@ -2503,12 +2419,6 @@ dialog::backdrop { font-weight: 600; } -.workspace-actions { - display: flex; - align-items: center; - gap: 10px; -} - .workspace-password-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2541,22 +2451,22 @@ dialog::backdrop { .workspace-password-card__controls { display: grid; - grid-template-columns: 106px auto; + grid-template-columns: minmax(160px, 220px) auto; align-items: center; gap: 6px; } .workspace-password-card__controls input { - width: 7vh; - height: 5vh; - min-width: 11vh; - padding: 0 1vh; + width: 100%; + min-width: 0; + min-height: 36px; + padding: 0 10px; } .workspace-password-card__controls button { - min-width: 7vh; - min-height: 4vh; - padding: 0 1vh; + min-width: 64px; + min-height: 36px; + padding: 0 12px; } .workspace-password-card>.form-message { @@ -2694,11 +2604,6 @@ dialog::backdrop { align-items: flex-start; } - .workspace-actions { - align-items: stretch; - flex-direction: column-reverse; - } - .workspace-password-card { grid-template-columns: 1fr; } @@ -3244,12 +3149,6 @@ dialog::backdrop { flex: 1 0 100%; } -.identity-panel__close { - position: absolute; - top: 14px; - right: 14px; -} - .auth-panel { display: grid; gap: 12px; @@ -4810,25 +4709,6 @@ dialog::backdrop { gap: 10px; } -.document-owner-badge { - max-width: 50%; - overflow: hidden; - padding: 2px 8px; - border: 1px solid color-mix(in srgb, var(--owner) 58%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--owner) 14%, transparent); - color: var(--text); - font-size: .7rem; - font-weight: 600; - line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; -} - -.document-owner-badge[hidden] { - display: none; -} - /* Per-character authorship overlay. The textarea remains the editable surface. */ .authorship-layer { position: absolute; @@ -5754,22 +5634,6 @@ dialog::backdrop { } } -.public-page-options { - display: grid; - gap: 4px; - align-content: center; -} - -.public-page-options .public-task-toggle { - min-height: 24px; -} - -@media (max-width: 720px) { - .public-page-options { - width: 100%; - } -} - /* Keep the whole editor surface consistent in Simple and Full modes. */ .editor-shell { background: var(--surface-inset); @@ -5793,7 +5657,6 @@ dialog::backdrop { z-index: 5; } - /* Stable editor canvas in both authorship modes. */ .pad-page .editor-column, .pad-page .editor-shell { @@ -6035,23 +5898,6 @@ dialog::backdrop { gap: 7px; } -.authorship-color-toggle { - display: inline-flex; - align-items: center; - gap: 5px; - min-height: 22px; - color: var(--muted); - font-size: 11px; - cursor: pointer; -} - -.authorship-color-toggle input { - width: 28px; - height: 16px; - margin: 0; - accent-color: var(--accent); -} - .authorship-layer { overflow: hidden; padding: 0; @@ -6154,7 +6000,6 @@ dialog::backdrop { background: var(--accent); } - .share-link-row .share-link-info { margin-top: 8px; justify-self: start; @@ -6166,7 +6011,6 @@ dialog::backdrop { white-space: pre-wrap; } - /* Keep generated individual links below the new-link form. */ .share-link-list-wrap { display: grid; @@ -6473,7 +6317,6 @@ dialog::backdrop { min-height: 34px; } - } /* Note editor polish: clearer actions, lighter canvas, and aligned split columns. */ @@ -6594,7 +6437,6 @@ dialog::backdrop { text-decoration: none; } - .resource-brand__kind { width: fit-content; padding-left: 0; @@ -6774,12 +6616,6 @@ dialog::backdrop { } /* Share links: keep one-time URLs readable without storing plaintext tokens. */ -.share-link-id { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: .78rem; - font-weight: 600; -} .share-link-once { color: var(--muted-2) !important; @@ -6828,7 +6664,7 @@ dialog::backdrop { } } -@media (max-width: 760px) { +@media (max-width: 760px) and (orientation: portrait) { .pad-page .editor-toolbar { grid-template-columns: minmax(0, 1fr) auto auto; width: 100%; @@ -6914,7 +6750,6 @@ dialog::backdrop { .history-header>div, .history-header h2, .history-header p, -.history-help, .history-list .empty, .history-list .error { min-width: 0; @@ -6940,13 +6775,16 @@ dialog::backdrop { } .page-password-requirement { - margin: 6px 0 0; - + margin: 2px 2px 0; color: var(--muted); - font-size: .69rem; + font-size: .72rem; line-height: 1.35; } +.page-settings.needs-password .page-password-requirement { + color: var(--text-secondary); +} + .page-password-requirement[hidden] { display: none; } @@ -6978,7 +6816,7 @@ dialog::backdrop { .page-password-inline__controls input { width: 100%; min-width: 0; - height: 50%; + min-height: 34px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; @@ -6998,8 +6836,8 @@ dialog::backdrop { } .page-password-inline__save { - min-width: 1vh; - height: 3vh; + min-width: 56px; + min-height: 34px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; @@ -7022,4 +6860,63 @@ dialog::backdrop { .page-password-inline .error { margin: 0; font-size: .68rem; -} \ No newline at end of file +} + +/* Mobile upload action shown directly below the formatting controls. */ +.mobile-upload-button { + display: none; +} + +@media (max-width: 760px) { + .pad-page .toolbar-group { + grid-column: 1; + grid-row: 1; + } + + .pad-page .view-switch { + grid-column: 2; + grid-row: 1; + } + + .pad-page .mobile-upload-button { + display: inline-flex; + grid-column: 1 / -1; + grid-row: 2; + width: 100%; + min-height: 36px; + align-items: center; + justify-content: center; + gap: 7px; + padding: 7px 12px; + border: 1px solid var(--toolbar-action-border); + border-radius: 8px; + background: var(--toolbar-action-bg); + color: var(--toolbar-action-text); + font: inherit; + font-weight: 700; + cursor: pointer; + } + + .pad-page .mobile-upload-button:hover { + border-color: color-mix(in srgb, var(--accent) 48%, var(--toolbar-action-border)); + background: var(--toolbar-action-hover); + } + + .pad-page .mobile-upload-button:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; + } +} + +@media (max-width: 760px) and (orientation: portrait) { + .pad-page #mode-toggle { + grid-column: 2; + grid-row: 1; + } + + .pad-page .view-switch { + grid-column: 3; + grid-row: 1; + } +} + diff --git a/static/editor.html b/static/editor.html index 18ab7e2..302f6bd 100644 --- a/static/editor.html +++ b/static/editor.html @@ -43,17 +43,7 @@ Page -
- +
- + + +
+