big dev changes
This commit is contained in:
+1
-1
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE notes ADD COLUMN created_by TEXT;
|
||||
+98
-5
@@ -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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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, ¬e_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, ¬e_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, ¬e_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
@@ -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(
|
||||
|
||||
@@ -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,10 +131,19 @@ 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())
|
||||
.await
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool.pool())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_note(
|
||||
@@ -109,11 +151,19 @@ 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)
|
||||
.fetch_optional(pool.pool())
|
||||
.await
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool.pool())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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})`;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})`;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})`;
|
||||
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
@@ -43,14 +43,40 @@ function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":
|
||||
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})`;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})`;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})`;
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user