fixes and functions
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.34"
|
||||
version = "0.2.35"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+10
-17
@@ -16,7 +16,7 @@ pub async fn upload_pad_file(
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_upload_permission(&state, &headers).await?;
|
||||
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = 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<Json<serde_json::Value>, ApiError> {
|
||||
require_upload_permission(&state, &headers).await?;
|
||||
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = 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<usize, ApiError> {
|
||||
upload_limit_for_request(state, headers)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
|
||||
}
|
||||
|
||||
pub async fn download_file(
|
||||
|
||||
+31
-2
@@ -173,6 +173,32 @@ async fn has_write_permission(
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_limit_for_request(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<usize>, 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<Option<usize>, 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<String>,
|
||||
@@ -380,6 +406,7 @@ pub struct NoteInfo {
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
can_upload_files: bool,
|
||||
upload_max_size_bytes: Option<usize>,
|
||||
global_color: Option<String>,
|
||||
note_color: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct PadInfo {
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
can_upload_files: bool,
|
||||
upload_max_size_bytes: Option<usize>,
|
||||
global_color: Option<String>,
|
||||
note_color: Option<String>,
|
||||
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,
|
||||
|
||||
+2
-4
@@ -46,7 +46,7 @@ impl<B> MakeSpan<B> 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))
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ pub fn render_html(
|
||||
}
|
||||
|
||||
pub fn theme_bootstrap() -> &'static str {
|
||||
r#"<script>(()=>{const key="rustpad:theme";let theme="dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
|
||||
r#"<script>(()=>{const key="rustpad:theme";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
|
||||
}
|
||||
|
||||
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
||||
|
||||
+73
-8
@@ -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<usize, Box<dyn std::error::Error>> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-1
@@ -64,6 +64,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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);
|
||||
|
||||
@@ -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<String>,
|
||||
pub smtp: Option<SmtpConfig>,
|
||||
@@ -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<String>,
|
||||
smtp: Option<SmtpConfig>,
|
||||
@@ -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,
|
||||
|
||||
+78
-181
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-16
@@ -43,17 +43,7 @@
|
||||
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
|
||||
class="page-settings__status" aria-hidden="true"></span>Page</span><span
|
||||
class="page-settings__chevron" aria-hidden="true">▾</span></summary>
|
||||
<div class="page-settings-menu"><button id="publish-page" class="page-settings-action"
|
||||
type="button"><span>Open page</span><small>Copy its link and open it in a new
|
||||
tab</small></button>
|
||||
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
|
||||
title="Enable or disable the published page"><input id="public-page-enabled"
|
||||
type="checkbox"> Enable Page</label><label class="public-task-toggle"
|
||||
title="Allow visitors to update task checkboxes on the published page"><input
|
||||
id="public-task-updates" type="checkbox"> Editable tasks</label><label
|
||||
class="public-task-toggle"
|
||||
title="Allow the published page to open without the resource password or private access"><input
|
||||
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
|
||||
<div class="page-settings-menu">
|
||||
<p id="page-password-requirement" class="page-password-requirement" hidden>Access to page
|
||||
options requires a password-protected note.</p>
|
||||
<form id="set-page-password-form" class="page-password-inline" hidden>
|
||||
@@ -65,8 +55,18 @@
|
||||
minlength="8" maxlength="128" autocomplete="new-password" placeholder="Min. 8 chars"
|
||||
aria-describedby="set-page-password-help" required><button type="submit"
|
||||
class="page-password-inline__save">Set</button></div>
|
||||
<small id="set-page-password-error" class="error"></small>
|
||||
<small id="set-page-password-error" class="error" role="alert" aria-live="polite"></small>
|
||||
</form>
|
||||
<button id="publish-page" class="page-settings-action" type="button"><span>Open
|
||||
page</span><small>Copy its link and open it in a new tab</small></button>
|
||||
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
|
||||
title="Enable or disable the published page"><input id="public-page-enabled"
|
||||
type="checkbox"> Enable Page</label><label class="public-task-toggle"
|
||||
title="Allow visitors to update task checkboxes on the published page"><input
|
||||
id="public-task-updates" type="checkbox"> Editable tasks</label><label
|
||||
class="public-task-toggle"
|
||||
title="Allow the published page to open without the resource password or private access"><input
|
||||
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
|
||||
</div>
|
||||
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
|
||||
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
|
||||
@@ -116,6 +116,8 @@
|
||||
data-format="horizontal-rule">Horizontal rule</button></div>
|
||||
</details>
|
||||
</div>
|
||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
|
||||
title="Upload an image or file"><span aria-hidden="true">📎</span> Upload file</button>
|
||||
<div class="editor-controls"><label>Font<select id="font-family">
|
||||
<option value="mono">Mono</option>
|
||||
<option value="system">System</option>
|
||||
@@ -139,9 +141,9 @@
|
||||
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
|
||||
class="control-label-full">Markdown</span><span class="control-label-short"
|
||||
aria-hidden="true">M</span></button>
|
||||
<div class="view-switch" aria-label="Editor view"><button data-view="edit" aria-label="Edit"
|
||||
title="Edit"><span class="control-label-full">Edit</span><span class="control-label-short"
|
||||
aria-hidden="true">E</span></button><button data-view="split" class="active"
|
||||
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
|
||||
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
|
||||
class="control-label-short" aria-hidden="true">E</span></button><button data-view="split"
|
||||
aria-label="Split" title="Split"><span class="control-label-full">Split</span><span
|
||||
class="control-label-short" aria-hidden="true">S</span></button><button data-view="preview"
|
||||
aria-label="Preview" title="Preview"><span class="control-label-full">Preview</span><span
|
||||
@@ -154,7 +156,7 @@
|
||||
interrupted</strong><span id="connection-notice-message">Trying to reconnect
|
||||
automatically.</span></span>
|
||||
</div>
|
||||
<div id="editor-workspace" class="workspace view-split">
|
||||
<div id="editor-workspace" class="workspace view-edit">
|
||||
<div class="editor-column">
|
||||
<div class="column-label editor-column-label"><span>Editor</span>
|
||||
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
|
||||
|
||||
+4
-4
@@ -85,9 +85,9 @@ async function clearSessionIfInvalid() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadSize(body) {
|
||||
function validateUploadSize(body, configuredMaxBytes) {
|
||||
if (!(body instanceof FormData)) return;
|
||||
const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0);
|
||||
const maxBytes = Number(configuredMaxBytes ?? window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes ?? 0);
|
||||
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
|
||||
for (const value of body.values()) {
|
||||
if (value instanceof File && value.size > maxBytes) {
|
||||
@@ -123,7 +123,7 @@ function formDataFileSize(body) {
|
||||
}
|
||||
|
||||
export async function api(path, options = {}) {
|
||||
validateUploadSize(options.body);
|
||||
validateUploadSize(options.body, options.uploadMaxSizeBytes);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
@@ -166,7 +166,7 @@ export async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
export function uploadWithProgress(path, options = {}) {
|
||||
validateUploadSize(options.body);
|
||||
validateUploadSize(options.body, options.uploadMaxSizeBytes);
|
||||
const method = options.method || "POST";
|
||||
const fallbackTotal = formDataFileSize(options.body);
|
||||
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
||||
|
||||
@@ -161,25 +161,6 @@ export function mapSelectionThroughEdit(previousText, nextText, start, end = sta
|
||||
end: Math.max(0, Math.min(nextText.length, map(end))),
|
||||
};
|
||||
}
|
||||
export function lineOwners(content, model) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
return starts.map((start, index) => {
|
||||
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
||||
const totals = new Map();
|
||||
const representatives = new Map();
|
||||
for (const span of model?.spans || []) {
|
||||
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
|
||||
if (!overlap) continue;
|
||||
const identity = ownerIdentity(span.owner);
|
||||
totals.set(identity, (totals.get(identity) || 0) + overlap);
|
||||
representatives.set(identity, span.owner);
|
||||
}
|
||||
const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
return identity ? representatives.get(identity) : "";
|
||||
});
|
||||
}
|
||||
|
||||
export function syncAuthorshipLayer(layer, editor) {
|
||||
if (!layer || !editor) return;
|
||||
const canvas = layer.querySelector(".authorship-canvas");
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
|
||||
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function utf16Length(value) {
|
||||
return String(value || "").length;
|
||||
}
|
||||
|
||||
function normalizeOwnerSpans(spans, length) {
|
||||
const result = [];
|
||||
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
|
||||
@@ -244,23 +240,6 @@ export function transformOperations(leftOperation, rightOperation, leftBeforeRig
|
||||
return [{ components: leftPrime }, { components: rightPrime }];
|
||||
}
|
||||
|
||||
export function applyOperation(text, operation) {
|
||||
text = String(text || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== text.length) throw new Error("Operation base length does not match document");
|
||||
let cursor = 0;
|
||||
let result = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
result += text.slice(cursor, cursor + component.count);
|
||||
cursor += component.count;
|
||||
} else if (component.kind === "delete") cursor += component.count;
|
||||
else result += component.text;
|
||||
}
|
||||
if (cursor !== text.length) throw new Error("Operation did not consume the whole document");
|
||||
return result;
|
||||
}
|
||||
|
||||
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
|
||||
const sourceEnd = sourceStart + length;
|
||||
for (const span of spans || []) {
|
||||
@@ -303,10 +282,6 @@ export function applyOperationToDocument(content, ownerMap, operation, ownerRepl
|
||||
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
|
||||
}
|
||||
|
||||
export function operationEquals(left, right) {
|
||||
return JSON.stringify(normalizeOperation(left)) === JSON.stringify(normalizeOperation(right));
|
||||
}
|
||||
|
||||
export function compareOperationKeys(left, right) {
|
||||
const leftClient = String(left?.clientId || left?.client_id || "");
|
||||
const rightClient = String(right?.clientId || right?.client_id || "");
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
|
||||
if (password.value) payload.password = password.value;
|
||||
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
|
||||
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
|
||||
window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`));
|
||||
window.location.assign(safeAppUrl(result.url));
|
||||
} catch (requestError) {
|
||||
error.textContent = requestError.message;
|
||||
} finally {
|
||||
|
||||
@@ -100,26 +100,6 @@ export function buildImageAlias(options = {}) {
|
||||
return `[${kind}=${filename},${parts.join(",")}]`;
|
||||
}
|
||||
|
||||
export function updateImageAliasInLine(line, aliasIndex, patch = {}) {
|
||||
const source = String(line || "");
|
||||
const targetIndex = Number(aliasIndex);
|
||||
if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
|
||||
|
||||
const codeRanges = inlineCodeRanges(source);
|
||||
let index = 0;
|
||||
let changed = false;
|
||||
const value = source.replace(imageAliasPattern(), (match, ...args) => {
|
||||
const offset = args.at(-2);
|
||||
if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
|
||||
const parsed = parseImageAlias(match);
|
||||
if (!parsed) return match;
|
||||
changed = true;
|
||||
return buildImageAlias({ ...parsed, ...patch });
|
||||
});
|
||||
|
||||
return changed ? value : null;
|
||||
}
|
||||
|
||||
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
|
||||
const source = String(line || "");
|
||||
const target = String(aliasSource || "");
|
||||
|
||||
@@ -119,7 +119,7 @@ export function startNoteEditor(adapter) {
|
||||
redo() { return this.move(1); },
|
||||
};
|
||||
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
||||
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
|
||||
const singlePaneQuery = window.matchMedia("(max-width: 760px) and (orientation: landscape)");
|
||||
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
||||
let renderedView = singlePaneQuery.matches ? compactView : uiState.view;
|
||||
let refreshFilesForAliases = () => { };
|
||||
@@ -1241,6 +1241,7 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
const { loadFiles } = bindNoteFiles({
|
||||
editor, toast, getAccessToken: () => accessToken,
|
||||
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
||||
canDelete: () => Boolean(info?.can_delete_files),
|
||||
canUpload: () => Boolean(info?.can_upload_files),
|
||||
canEdit: canEditDocument,
|
||||
@@ -1374,6 +1375,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
applyUi();
|
||||
try {
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
@@ -1426,7 +1428,10 @@ export function startNoteEditor(adapter) {
|
||||
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
|
||||
});
|
||||
headerActions.addEventListener("click", event => {
|
||||
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
|
||||
const button = event.target.closest("button");
|
||||
if (compactLayoutQuery.matches && button && !button.closest(".page-settings-menu")) {
|
||||
setHeaderMenuOpen(false);
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", event => {
|
||||
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
|
||||
@@ -1860,6 +1865,7 @@ export function startNoteEditor(adapter) {
|
||||
function updatePageControls() {
|
||||
const passwordProtected = Boolean(info?.protected);
|
||||
const workspacePassword = adapter.passwordScope === "workspace";
|
||||
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
|
||||
if (!passwordProtected) {
|
||||
publicPageEnabled.checked = false;
|
||||
unprotectPublicPage.checked = false;
|
||||
@@ -1868,12 +1874,15 @@ export function startNoteEditor(adapter) {
|
||||
? "Access to page options requires a password-protected workspace."
|
||||
: "Access to page options requires a password-protected note.";
|
||||
if (pagePasswordRequirement) {
|
||||
pagePasswordRequirement.textContent = requirementText;
|
||||
pagePasswordRequirement.textContent = canSetPassword
|
||||
? `Set a ${workspacePassword ? "workspace" : "note"} password here to enable Page publishing.`
|
||||
: requirementText;
|
||||
pagePasswordRequirement.hidden = passwordProtected;
|
||||
}
|
||||
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Set workspace password" : "Set password";
|
||||
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword ? "Protects the entire workspace. Minimum 8 characters." : "Minimum 8 characters.";
|
||||
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
|
||||
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Workspace password" : "Note password";
|
||||
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword
|
||||
? "At least 8 characters. It protects the workspace and all notes."
|
||||
: "At least 8 characters. It also protects editing access.";
|
||||
setPagePasswordForm.hidden = !canSetPassword;
|
||||
const enabled = passwordProtected && publicPageEnabled.checked;
|
||||
publicPageEnabled.disabled = !passwordProtected;
|
||||
@@ -1885,6 +1894,7 @@ export function startNoteEditor(adapter) {
|
||||
"false",
|
||||
);
|
||||
pageSettings?.classList.toggle("is-enabled", enabled);
|
||||
pageSettings?.classList.toggle("needs-password", canSetPassword);
|
||||
pageSettings?.querySelector("summary")?.setAttribute(
|
||||
"title",
|
||||
!passwordProtected
|
||||
@@ -1896,6 +1906,11 @@ export function startNoteEditor(adapter) {
|
||||
: "Published page disabled",
|
||||
);
|
||||
}
|
||||
pageSettings?.addEventListener("toggle", () => {
|
||||
if (pageSettings.open && !setPagePasswordForm.hidden) {
|
||||
requestAnimationFrame(() => setPagePasswordInput.focus());
|
||||
}
|
||||
});
|
||||
document.addEventListener("pointerdown", event => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
|
||||
@@ -1929,6 +1944,8 @@ export function startNoteEditor(adapter) {
|
||||
loadFiles();
|
||||
connect();
|
||||
toast("Password set. Page options are now available.");
|
||||
pageSettings.open = true;
|
||||
requestAnimationFrame(() => publicPageEnabled.focus());
|
||||
} catch (error) {
|
||||
setPagePasswordError.textContent = error.message;
|
||||
} finally {
|
||||
|
||||
@@ -70,7 +70,7 @@ function safeAttachmentUrl(value) {
|
||||
: safePublicUrl(raw, { allowMailto: false });
|
||||
}
|
||||
|
||||
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
|
||||
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
|
||||
const dialog = document.querySelector("#files-dialog");
|
||||
const list = document.querySelector("#files-list");
|
||||
const input = document.querySelector("#file-input");
|
||||
@@ -126,6 +126,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
|
||||
method: "POST",
|
||||
body: form,
|
||||
headers: {},
|
||||
uploadMaxSizeBytes: getUploadMaxSize(),
|
||||
onProgress: progress => uploadToast.update(progress),
|
||||
});
|
||||
if (completed) return;
|
||||
@@ -143,13 +144,16 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
|
||||
await run();
|
||||
}
|
||||
|
||||
document.querySelector("#upload-button").addEventListener("click", () => {
|
||||
function requestUpload() {
|
||||
if (!canUpload() || !canEdit()) {
|
||||
toast("Log in with read-write access to upload files.");
|
||||
toast("You need read-write access and upload permission to upload files.");
|
||||
return;
|
||||
}
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector("#upload-button")?.addEventListener("click", requestUpload);
|
||||
document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload);
|
||||
|
||||
input.addEventListener("change", async event => {
|
||||
let file = event.target.files[0];
|
||||
@@ -175,7 +179,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
if (!canUpload() || !canEdit()) {
|
||||
toast("Log in with read-write access to paste files.");
|
||||
toast("You need read-write access and upload permission to paste files.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,3 @@ export function safePublicUrl(value, { allowMailto = true } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function safeHexColor(value, fallback = "#64748b") {
|
||||
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value) : fallback;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,3 @@ export function clearAuthSession() {
|
||||
sessionStorage.removeItem(NICKNAME_KEY);
|
||||
setNicknameCookie("");
|
||||
}
|
||||
export async function resolveIdentity(api, nickname) {
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname }) });
|
||||
setNickname(result.nickname); return result;
|
||||
}
|
||||
|
||||
+15
-3
@@ -7,11 +7,15 @@
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
const STORAGE_KEY = "rustpad:theme";
|
||||
const DEFAULT_THEME = "dark";
|
||||
const THEMES = new Set(["dark", "light"]);
|
||||
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
|
||||
function systemTheme() {
|
||||
return systemThemeQuery.matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
function normalizeTheme(value) {
|
||||
return THEMES.has(value) ? value : DEFAULT_THEME;
|
||||
return THEMES.has(value) ? value : systemTheme();
|
||||
}
|
||||
|
||||
function updateBrowserChrome(theme) {
|
||||
@@ -42,5 +46,13 @@ export function applySessionTheme(session) {
|
||||
}
|
||||
|
||||
window.addEventListener("storage", event => {
|
||||
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false });
|
||||
if (event.key !== STORAGE_KEY) return;
|
||||
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false });
|
||||
});
|
||||
|
||||
systemThemeQuery.addEventListener("change", () => {
|
||||
try {
|
||||
if (THEMES.has(localStorage.getItem(STORAGE_KEY))) return;
|
||||
} catch { }
|
||||
applyTheme(systemTheme(), { persist: false });
|
||||
});
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
const VIEWS = new Set(["edit", "split", "preview"]);
|
||||
const MODES = new Set(["markdown", "text"]);
|
||||
|
||||
function defaultEditorView() {
|
||||
return window.matchMedia("(max-width: 760px)").matches ? "edit" : "split";
|
||||
}
|
||||
|
||||
export function readEditorState() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedView = params.get("view");
|
||||
return {
|
||||
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
|
||||
view: VIEWS.has(requestedView) ? requestedView : defaultEditorView(),
|
||||
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ function renderNotes(notes = notesCache) {
|
||||
if (notesView === "table") {
|
||||
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
||||
<tr>
|
||||
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
|
||||
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
|
||||
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
||||
<td>${Number(note.participant_count) || 0}</td>
|
||||
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
|
||||
@@ -106,7 +106,7 @@ function renderNotes(notes = notesCache) {
|
||||
}
|
||||
notesList.innerHTML = notes.map(note => `
|
||||
<article class="note-card-wrap">
|
||||
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
|
||||
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
|
||||
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
||||
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
|
||||
</a>
|
||||
@@ -218,7 +218,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
|
||||
});
|
||||
location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`));
|
||||
location.assign(safeAppUrl(note.url));
|
||||
} catch (err) { error.textContent = err.message; }
|
||||
});
|
||||
notesList.addEventListener("click", async event => {
|
||||
|
||||
@@ -13,6 +13,8 @@ files_dir: /var/lib/rustpad/files
|
||||
# files_public_url: files.note.example.org
|
||||
storage_driver: local
|
||||
upload_max_size_mb: 20
|
||||
guest_upload_enabled: false
|
||||
guest_upload_max_size_mb: 5
|
||||
asset_cache_max_age_seconds: 600
|
||||
file_cache_max_age_seconds: 600
|
||||
|
||||
|
||||
Reference in New Issue
Block a user