big dev changes

This commit is contained in:
Mateusz Gruszczyński
2026-07-21 10:11:57 +02:00
parent 4bb92a343b
commit 0b4b1753a0
23 changed files with 690 additions and 51 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "rustpad"
version = "0.0.1-dev"
edition = "2024"
rust-version = "1.97"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
license = "MIT"
Binary file not shown.
@@ -0,0 +1,8 @@
ALTER TABLE notes ADD COLUMN protected BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE note_files (
id BIGINT AUTO_INCREMENT PRIMARY KEY, note_id BIGINT NOT NULL, filename TEXT NOT NULL, url VARCHAR(512) NOT NULL UNIQUE,
mime_type VARCHAR(255) NOT NULL, size_bytes BIGINT NOT NULL, created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
is_attached BOOLEAN NOT NULL DEFAULT TRUE, detached_at VARCHAR(64),
CONSTRAINT fk_note_files_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
INDEX idx_note_files_note (note_id, id DESC)
) ENGINE=InnoDB;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE pad_files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
pad_id BIGINT NOT NULL,
filename TEXT NOT NULL,
url VARCHAR(512) NOT NULL UNIQUE,
mime_type VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
is_attached BOOLEAN NOT NULL DEFAULT TRUE,
detached_at VARCHAR(64),
CONSTRAINT fk_pad_files_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
INDEX idx_pad_files_pad (pad_id, id DESC)
) ENGINE=InnoDB;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE notes ADD COLUMN created_by TEXT;
@@ -0,0 +1,7 @@
ALTER TABLE notes ADD COLUMN protected BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE note_files (
id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, filename TEXT NOT NULL, url TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL, size_bytes BIGINT NOT NULL, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
is_attached BOOLEAN NOT NULL DEFAULT TRUE, detached_at TEXT
);
CREATE INDEX idx_note_files_note ON note_files(note_id, id DESC);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE pad_files (
id BIGSERIAL PRIMARY KEY,
pad_id BIGINT NOT NULL REFERENCES pads(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
is_attached BOOLEAN NOT NULL DEFAULT TRUE,
detached_at TEXT
);
CREATE INDEX idx_pad_files_pad ON pad_files(pad_id, id DESC);
@@ -0,0 +1 @@
ALTER TABLE notes ADD COLUMN created_by TEXT;
@@ -0,0 +1,32 @@
ALTER TABLE pads ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
ALTER TABLE pads ADD COLUMN file_token TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pads_file_token ON pads(file_token);
ALTER TABLE revisions ADD COLUMN author TEXT;
ALTER TABLE revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, password_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, owner_map TEXT NOT NULL DEFAULT '[]', file_token TEXT UNIQUE,
protected INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, UNIQUE(workspace_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS note_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
author TEXT, owner_map TEXT NOT NULL DEFAULT '[]', FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC);
CREATE TABLE IF NOT EXISTS published_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, pad_id INTEGER UNIQUE, note_id INTEGER UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL))
);
CREATE TABLE IF NOT EXISTS note_files (
id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, filename TEXT NOT NULL, url TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, is_attached INTEGER NOT NULL DEFAULT 1, detached_at TEXT,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_note_files_note ON note_files(note_id, id DESC);
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS pad_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pad_id INTEGER NOT NULL,
filename TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_attached INTEGER NOT NULL DEFAULT 1,
detached_at TEXT,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pad_files_pad ON pad_files(pad_id, id DESC);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE notes ADD COLUMN created_by TEXT;
+98 -5
View File
@@ -53,6 +53,10 @@ pub struct CreateNoteRequest {
name: String,
#[serde(default)]
password: Option<String>,
#[serde(default)]
protect: bool,
#[serde(default)]
created_by: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -84,6 +88,8 @@ pub struct NoteListItem {
created_at: String,
updated_at: String,
url: String,
protected: bool,
created_by: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -93,6 +99,7 @@ pub struct NoteInfo {
slug: String,
title: String,
protected: bool,
note_protected: bool,
created_at: String,
updated_at: String,
}
@@ -141,6 +148,8 @@ pub async fn open_workspace(
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
})
.collect();
@@ -163,7 +172,8 @@ pub async fn create_note(
}
let slug = unique_note_slug(&state, workspace.id, &base).await?;
let note = db::create_note(&state.db, workspace.id, &slug, title).await?;
let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::<String>());
let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).await?;
Ok((
StatusCode::CREATED,
Json(NoteListItem {
@@ -172,6 +182,8 @@ pub async fn create_note(
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
}),
))
}
@@ -193,6 +205,7 @@ pub async fn note_info(
slug: note.slug,
title: note.title,
protected: workspace.password_hash.is_some(),
note_protected: note.protected,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
}))
@@ -547,8 +560,30 @@ pub async fn upload_pad_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored);
}
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string();
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
}
pub async fn pad_files(
State(state): State<SharedState>,
Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = pad.content.contains(&file.url);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
}
file.created_at = db::normalize_timestamp(&file.created_at);
}
Ok(Json(files))
}
pub async fn upload_note_file(
@@ -586,11 +621,69 @@ pub async fn upload_note_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored);
}
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string();
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
}
pub async fn delete_note(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); }
db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn note_files(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = note.content.contains(&file.url);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
}
file.created_at = db::normalize_timestamp(&file.created_at);
}
Ok(Json(files))
}
pub async fn delete_note_file(
State(state): State<SharedState>,
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() {
return Err(ApiError::unauthorized());
}
let file = db::find_note_file(&state.db, note.id, file_id).await?
.ok_or_else(ApiError::not_found_file)?;
let relative = file.url.trim_start_matches('/').split('/').collect::<Vec<_>>();
if relative.len() == 3 && relative[0] == "f" {
let directory = format!("{}_{}", note.id, relative[1]);
let path = std::path::Path::new(&state.files_dir).join("notes").join(directory).join(sanitize_filename(relative[2]));
match tokio::fs::remove_file(&path).await {
Ok(()) => {},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
Err(_) => return Err(ApiError::internal("Failed to delete the file")),
}
}
db::delete_note_file(&state.db, note.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn download_file(
State(state): State<SharedState>,
Path((token, filename)): Path<(String, String)>,
+7 -3
View File
@@ -25,14 +25,14 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/pads/{slug}/history", post(api::pad_history))
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore))
.route("/api/pads/{slug}/files", post(api::upload_pad_file))
.route("/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files))
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
.route("/api/workspaces/{workspace_slug}/notes", post(api::create_note))
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info),
get(api::note_info).delete(api::delete_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
@@ -48,7 +48,11 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
post(api::upload_note_file),
post(api::upload_note_file).put(api::note_files),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}",
axum::routing::delete(api::delete_note_file),
)
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
.route(
+179 -5
View File
@@ -44,6 +44,39 @@ pub struct Note {
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub protected: bool,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNote {
id: i64,
workspace_id: i64,
slug: String,
title: String,
content: String,
created_at: String,
updated_at: String,
owner_map: String,
protected: i64,
created_by: Option<String>,
}
impl From<SqliteNote> for Note {
fn from(value: SqliteNote) -> Self {
Self {
id: value.id,
workspace_id: value.workspace_id,
slug: value.slug,
title: value.title,
content: value.content,
created_at: value.created_at,
updated_at: value.updated_at,
owner_map: value.owner_map,
protected: value.protected != 0,
created_by: value.created_by,
}
}
}
#[derive(Debug, Serialize, FromRow)]
@@ -98,6 +131,15 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
}
pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q003)
.bind(workspace_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(Note::from)
.collect());
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
@@ -109,6 +151,14 @@ pub async fn find_note(
workspace_id: i64,
slug: &str,
) -> Result<Option<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q004)
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await?
.map(Note::from));
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
@@ -121,19 +171,21 @@ pub async fn create_note(
workspace_id: i64,
slug: &str,
title: &str,
protected: bool,
created_by: Option<&str>,
) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id)
.bind(slug)
.bind(title)
.bind(protected)
.bind(created_by)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_one(pool.pool())
.await
find_note(pool, workspace_id, slug)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn save_revision(
@@ -453,3 +505,125 @@ pub async fn find_file_owner(pool: &Database, token: &str) -> Result<Option<File
}
Ok(None)
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NoteFile {
pub id: i64,
pub filename: String,
pub url: String,
pub mime_type: String,
pub size_bytes: i64,
pub created_at: String,
pub is_attached: bool,
pub detached_at: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNoteFile {
id: i64,
filename: String,
url: String,
mime_type: String,
size_bytes: i64,
created_at: String,
is_attached: i64,
detached_at: Option<String>,
}
impl From<SqliteNoteFile> for NoteFile {
fn from(value: SqliteNoteFile) -> Self {
Self {
id: value.id,
filename: value.filename,
url: value.url,
mime_type: value.mime_type,
size_bytes: value.size_bytes,
created_at: value.created_at,
is_attached: value.is_attached != 0,
detached_at: value.detached_at,
}
}
}
pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q031)).bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn register_note_file(pool: &Database, note_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q032))
.bind(note_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes)
.execute(pool.pool()).await?;
Ok(())
}
pub async fn list_note_files(pool: &Database, note_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q033, note_id).await
}
async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(query)
.bind(owner_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(NoteFile::from)
.collect());
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await
}
pub async fn set_note_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
sqlx::query(queries::get(pool.kind(), queries::Q034)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn register_pad_file(pool: &Database, pad_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q035))
.bind(pad_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes)
.execute(pool.pool()).await?;
Ok(())
}
pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q036, pad_id).await
}
pub async fn set_pad_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
sqlx::query(queries::get(pool.kind(), queries::Q037)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038)
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q039))
.bind(file_id)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
+12 -3
View File
@@ -3,9 +3,9 @@ use crate::database::DatabaseKind;
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?";
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)";
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?";
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)";
pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)";
@@ -31,6 +31,15 @@ pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?";
pub const Q028: &str = "SELECT content FROM note_revisions WHERE id = ? AND note_id = ?";
pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q031: &str = "DELETE FROM notes WHERE id = ?";
pub const Q032: &str = "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC";
pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q035: &str = "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC";
pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?";
pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
+1 -1
View File
@@ -16,7 +16,7 @@ export async function prepareImageFile(file){
<div class="image-editor-controls">
<label>Crop<select data-aspect><option value="free">Free</option><option value="1">Square</option><option value="1.333333">4:3</option><option value="1.777778">16:9</option></select></label>
<label>Zoom<input data-zoom type="range" min="1" max="3" value="1" step="0.01"></label>
<label>Max size<select data-size><option value="1200">1200 px</option><option value="1600" selected>1600 px</option><option value="2000">2000 px</option><option value="0">Original</option></select></label>
<label>Max size<select data-size><option value="320">320 px</option><option value="480">480 px</option><option value="640">640 px</option><option value="800">800 px</option><option value="1000">1000 px</option><option value="1200">1200 px</option><option value="1600" selected>1600 px</option><option value="2000">2000 px</option><option value="0">Original</option></select></label>
</div>
<div class="image-editor-actions"><button class="secondary-button" value="cancel">Cancel</button><button type="button" class="primary-button" data-apply>Use image</button></div>
</form>`;
+37 -4
View File
@@ -44,15 +44,48 @@ function render(){if(uiState.mode==="markdown"){preview.classList.remove("previe
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();});
function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
async function loadFiles({open=false}={}){
try{
const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})});
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`;
const list=document.querySelector("#files-list");
list.innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · <span class="file-flag ${file.is_attached?"":"detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>${info?.protected&&password?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="empty">No files uploaded.</p>';
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
}catch(error){toast(error.message);}
}
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format)));
document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}});
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";connect();});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";loadFiles();connect();});
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true}));
document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true}));
document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close());
document.querySelector("#files-list").addEventListener("click",async event=>{
const showButton=event.target.closest("[data-show-file-code]");
if(showButton){
const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea");
const absolute=new URL(showButton.dataset.url,location.origin).href;
let text=absolute;
if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?`![${showButton.dataset.name}](${absolute})`:`[${showButton.dataset.name}](${absolute})`;
if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`<img src="${absolute}" alt="${showButton.dataset.name}">`:`<a href="${absolute}">${showButton.dataset.name}</a>`;
output.value=text;panel.hidden=false;output.focus();output.select();return;
}
const copyButton=event.target.closest("[data-copy-generated]");
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
const deleteButton=event.target.closest("[data-delete-file]");
if(deleteButton){
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
}
});
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);});
window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);});
initialize();
+29 -3
View File
@@ -43,14 +43,40 @@ function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
async function loadFiles({open=false}={}){
try{
const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})});
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`;
document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${escapeHtml(file.mime_type)} · ${Math.max(1,Math.round(file.size_bytes/1024))} KB · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached?"":" detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button></div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="dialog-copy">No files uploaded.</p>';
if(open)document.querySelector("#files-dialog").showModal();
}catch(error){if(open)toast(error.message);}
}
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();});
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format)));
document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}});
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();});
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true}));
document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true}));
document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close());
document.querySelector("#files-list").addEventListener("click",async event=>{
const showButton=event.target.closest("[data-show-file-code]");
if(showButton){
const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea");
const absolute=new URL(showButton.dataset.url,location.origin).href;
let text=absolute;
if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?`![${showButton.dataset.name}](${absolute})`:`[${showButton.dataset.name}](${absolute})`;
if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`<img src="${absolute}" alt="${showButton.dataset.name}">`:`<a href="${absolute}">${showButton.dataset.name}</a>`;
output.value=text;panel.hidden=false;output.focus();output.select();return;
}
const copyButton=event.target.closest("[data-copy-generated]");
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
});
initialize();
+138 -11
View File
@@ -1,12 +1,139 @@
import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { getPassword, setPassword } from "@rustpad/session";
const parts = location.pathname.split("/").filter(Boolean), slug = parts[1]; let info, password = getPassword(slug); const dialog = document.querySelector("#password-dialog"), notesList = document.querySelector("#notes-list");
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); }
function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `<a class="note-card" href="${note.url}?view=split&mode=markdown"><h3>${escapeHtml(note.title)}</h3><p>Updated: ${formatDate(note.updated_at)}</p></a>`).join("") : '<p class="empty">No notes yet.</p>'; }
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { getNickname, getPassword, setPassword } from "@rustpad/session";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
let info;
let password = getPassword(slug);
const dialog = document.querySelector("#password-dialog");
const notesList = document.querySelector("#notes-list");
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
let notesCache = [];
function toast(text) {
const el = document.querySelector("#toast");
el.textContent = text;
el.classList.add("visible");
setTimeout(() => el.classList.remove("visible"), 1600);
}
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatDate(value) { if (value == null || value === "") return "—"; let raw = String(value).trim(); if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) { raw = raw.replace(" ", "T") + "Z"; } const date = new Date(raw); return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("en-US"); }
async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } }
async function init() { try { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !password) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } }
document.querySelector("#password-form").addEventListener("submit", e => { e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); });
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); const error = document.querySelector("#note-error"); error.textContent = ""; try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } catch (e) { toast(e.message); } }); init();
function formatDate(value) {
if (value == null || value === "") return "—";
let raw = String(value).trim();
if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; }
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) raw = raw.replace(" ", "T") + "Z";
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pl-PL");
}
function setNotesView(view) {
notesView = view === "table" ? "table" : "grid";
localStorage.setItem(notesViewKey, notesView);
notesList.classList.toggle("notes-grid", notesView === "grid");
notesList.classList.toggle("notes-table", notesView === "table");
document.querySelectorAll("[data-notes-view]").forEach(button => {
const active = button.dataset.notesView === notesView;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
function deleteButton(note, inline = false) {
const disabled = note.protected;
const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`;
const reason = disabled ? "Protected notes cannot be deleted" : `Delete ${note.title}`;
return `<button class="${classes}" data-delete-note="${escapeHtml(note.slug)}" data-note-title="${escapeHtml(note.title)}" ${disabled ? "disabled" : ""} title="${escapeHtml(reason)}">Delete</button>`;
}
function renderNotes(notes = notesCache) {
notesCache = notes;
setNotesView(notesView);
if (!notes.length) {
notesList.innerHTML = '<p class="empty">No notes yet.</p>';
return;
}
if (notesView === "table") {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</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="${note.url}?view=split&mode=markdown">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${note.protected ? '<span class="protect-badge">Protected</span>' : '<span class="note-status">Editable</span>'}</td>
<td>${formatDate(note.updated_at)}</td>
<td class="notes-table-actions">${deleteButton(note, true)}</td>
</tr>`).join("")}</tbody></table></div>`;
return;
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${note.url}?view=split&mode=markdown">
<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><span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
${deleteButton(note)}
</article>`).join("");
}
async function openWorkspace() {
try {
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) });
info = data.workspace;
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
document.title = `${info.title} · RustPad`;
notesCache = data.notes;
renderNotes();
if (dialog.open) dialog.close();
} catch (e) {
if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
} else document.querySelector("#workspace-error").textContent = e.message;
}
}
async function init() {
try {
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`);
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
if (info.protected && !password) dialog.showModal(); else openWorkspace();
} catch (e) { document.querySelector("#workspace-error").textContent = e.message; }
}
document.querySelector("#password-form").addEventListener("submit", e => {
e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace();
});
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => {
e.preventDefault();
const error = document.querySelector("#note-error"); error.textContent = "";
try {
const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, {
method: "POST",
body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null, protect: document.querySelector("#note-protect").checked, created_by: getNickname() || null })
});
location.assign(`${note.url}?view=split&mode=markdown`);
} catch (err) { error.textContent = err.message; }
});
notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]");
if (!button) return;
const title = button.dataset.noteTitle;
if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return;
button.disabled = true;
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
method: "DELETE", body: JSON.stringify({ password: password || null })
});
toast("Note deleted");
await openWorkspace();
} catch (error) { toast(error.message); button.disabled = false; }
});
document.querySelectorAll("[data-notes-view]").forEach(button => button.addEventListener("click", () => {
if (button.dataset.notesView === notesView) return;
notesView = button.dataset.notesView;
renderNotes();
}));
document.querySelector("#copy-workspace-link").addEventListener("click", async () => {
try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); }
});
setNotesView(notesView);
init();
+3 -3
View File
@@ -1,5 +1,5 @@
<!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/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"><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 id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</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">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></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">14</option><option value="16">16</option><option value="18" selected>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"> 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><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span id="save-state">Changes are saved automatically</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="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
<body class="pad-page"><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 id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></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">14</option><option value="16">16</option><option value="18" selected>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"> 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><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span><button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="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" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></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>
+3 -3
View File
@@ -1,5 +1,5 @@
<!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/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"><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 id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</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">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></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">14</option><option value="16">16</option><option value="18" selected>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"> 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><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span id="save-state">Changes are saved automatically</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="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
<body class="pad-page"><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 id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></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">14</option><option value="16">16</option><option value="18" selected>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"> 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><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span><button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="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" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></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>
+85
View File
@@ -319,3 +319,88 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.public-header { padding-inline: max(20px, calc((100vw - 1180px) / 2)); }
.public-document { width: min(1180px, calc(100% - 32px)); }
.public-content { padding: clamp(24px, 4vw, 52px); }
/* Preview and attachment management */
.preview { overscroll-behavior: contain; scrollbar-gutter: stable; }
.markdown-body img { display: block; width: auto; max-width: 100%; height: auto; object-fit: contain; border-radius: 8px; }
.workspace.view-preview .markdown-body img { max-height: calc(100vh - 210px); }
.note-card-wrap { position: relative; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; }
.note-card-wrap .note-card { border: 0; border-radius: 0; }
.note-card-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.protect-badge, .file-flag { display: inline-flex; padding: 3px 7px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted); font-size: .68rem; white-space: nowrap; }
.file-flag.detached { border-color: rgba(255,123,145,.45); color: var(--danger); }
.note-delete-button { width: 100%; min-height: 36px; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--danger); }
.danger-button { color: var(--danger); }
.dialog-check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: .82rem; }
.dialog-check input { width: auto; min-height: auto; }
.footer-link { border: 0; background: transparent; color: var(--muted); padding: 0; font-size: inherit; text-decoration: underline; text-underline-offset: 2px; }
.files-dialog { overflow: hidden; }
.files-panel { grid-template-rows: auto minmax(0, 1fr); min-height: min(590px, calc(100vh - 28px)); max-height: calc(100vh - 28px); }
.files-list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.files-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.files-head h2, .files-head p { margin: 0; }
.files-head p { margin-top: 5px; color: var(--muted); font-size: .8rem; }
.files-list { display: grid; gap: 9px; overflow: auto; min-height: 80px; }
.file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: #0e1116; }
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
.file-meta { margin-top: 4px; color: var(--muted-2); font-size: .72rem; }
.file-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.file-actions button { min-height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); padding: 0 8px; font-size: .72rem; }
@media (max-width: 620px) { .file-row { grid-template-columns: 1fr; } .file-actions { justify-content: flex-start; } }
/* Attachment modal layout fixes. */
.files-panel { min-height: 0; height: min(590px, calc(100vh - 28px)); }
.files-list { align-content: start; }
.file-row { align-items: start; }
.file-row-main { min-width: 0; }
.file-actions { align-items: center; align-self: start; flex-wrap: nowrap; }
.file-actions button { min-height: 34px; height: 34px; white-space: nowrap; }
.file-code { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: stretch; }
.file-code[hidden] { display: none; }
.file-code textarea { width: 100%; min-height: 76px; max-height: 150px; resize: vertical; box-sizing: border-box; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.file-code button { align-self: stretch; min-width: 74px; }
.file-delete { color: var(--danger) !important; border-color: color-mix(in srgb, var(--danger) 45%, var(--border)) !important; }
@media (max-width: 760px) {
.file-actions { flex-wrap: wrap; justify-content: flex-start; }
.file-code { grid-template-columns: 1fr; }
}
/* Workspace notes: resilient cards and optional table view. */
.notes-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
.notes-toolbar__label { color: var(--muted); font-size: .82rem; font-weight: 600; }
.workspace-actions { display: flex; align-items: center; gap: 10px; }
.notes-view-switch { display: inline-flex; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
.notes-view-switch button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); }
.notes-view-switch button.active { background: var(--surface-3); color: var(--text); }
.note-card { display: block; min-width: 0; }
.note-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; min-width: 0; }
.note-card-title h3 { min-width: 0; overflow-wrap: anywhere; word-break: break-word; line-height: 1.4; }
.note-card p { overflow-wrap: anywhere; }
.notes-table { display: block; padding-top: 20px; }
.notes-table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.notes-table table { width: 100%; min-width: 680px; border-collapse: collapse; }
.notes-table th, .notes-table td { padding: 13px 15px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; }
.notes-table th { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
.notes-table tbody tr:last-child td { border-bottom: 0; }
.notes-table tbody tr:hover { background: var(--surface-2); }
.note-table-link { display: block; max-width: 52ch; color: var(--text); font-weight: 650; text-decoration: none; overflow-wrap: anywhere; }
.note-table-link:hover { text-decoration: underline; }
.note-status { color: var(--muted); font-size: .75rem; }
.notes-table-actions { width: 1%; white-space: nowrap; text-align: right !important; }
.note-delete-button--inline { width: auto; min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; }
@media (max-width: 700px) {
.workspace-top { align-items: flex-start; }
.workspace-actions { align-items: stretch; flex-direction: column-reverse; }
.notes-view-switch button { flex: 1; }
}
/* Workspace note ownership and immediate view switching. */
.note-card-meta { display: grid; gap: 5px; margin-top: 28px; color: var(--muted); font-size: .75rem; }
.note-card p { margin-top: 0; }
.note-author { color: var(--muted); overflow-wrap: anywhere; }
.note-delete-button:disabled { cursor: not-allowed; color: var(--muted-2); opacity: .55; }
.notes-view-switch button { cursor: pointer; }
.notes-view-switch button.active { cursor: default; }
+2 -2
View File
@@ -1,5 +1,5 @@
<!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/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><p id="workspace-error" class="form-message error"></p><section id="notes-list" class="notes-grid" aria-live="polite"></section></main>
<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"><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>
<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>