work
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
IMAGE_TAG=0.3.0
|
||||
RUSTPAD_PORT=3000
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=3000
|
||||
DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc
|
||||
DATABASE_MAX_CONNECTIONS=8
|
||||
STATIC_DIR=/app/static
|
||||
ASSET_VERSION=0.5.1
|
||||
RUST_LOG=rustpad=info,tower_http=info
|
||||
+3
-1
@@ -3,10 +3,12 @@ APP_HOST=0.0.0.0
|
||||
APP_PORT=3000
|
||||
|
||||
# SQLite
|
||||
DATABASE_URL=sqlite://rustpad.db?mode=rwc
|
||||
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
|
||||
DATABASE_MAX_CONNECTIONS=8
|
||||
|
||||
# Assety i logowanie
|
||||
STATIC_DIR=static
|
||||
ASSET_VERSION=0.0.1
|
||||
RUST_LOG=rustpad=debug,tower_http=info
|
||||
|
||||
UPLOAD_MAX_SIZE_MB=20
|
||||
@@ -5,3 +5,5 @@ rustpad.db-wal
|
||||
.env
|
||||
.env.docker
|
||||
*.log
|
||||
data/db/.db*
|
||||
data/files/*
|
||||
Generated
+29
-1
@@ -79,6 +79,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -332,6 +333,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -945,6 +955,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1305,13 +1332,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.6.0"
|
||||
version = "0.0.1-dev"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"mime_guess",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.6.0"
|
||||
version = "0.0.1-dev"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite"
|
||||
@@ -8,10 +8,11 @@ license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
futures-util = "0.3"
|
||||
mime_guess = "2"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+2
-1
@@ -23,9 +23,10 @@ COPY --from=builder /app/static ./static
|
||||
USER rustpad
|
||||
ENV APP_HOST=0.0.0.0 \
|
||||
APP_PORT=3000 \
|
||||
DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc \
|
||||
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc \
|
||||
DATABASE_MAX_CONNECTIONS=8 \
|
||||
STATIC_DIR=/app/static \
|
||||
FILES_DIR=/data/files \
|
||||
RUST_LOG=rustpad=info,tower_http=info
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,52 +1,38 @@
|
||||
# RustPad 0.6.0
|
||||
|
||||
Współdzielony edytor Markdown z dwoma niezależnymi trybami:
|
||||
Collaborative Markdown editor with standalone notes and workspaces.
|
||||
|
||||
- szybka notatka pod `/p/<slug>`,
|
||||
- workspace z wieloma notatkami pod `/w/<slug>`.
|
||||
|
||||
Oba tryby obsługują opcjonalne hasła, edycję na żywo, SQLite, historię zmian, podgląd Markdown i stan widoku zapisany w URL.
|
||||
|
||||
## Uruchomienie lokalne
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cargo run
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
Otwórz `http://127.0.0.1:3000`.
|
||||
The script creates `data/db` and `data/files`, builds the project, and runs it with Cargo. When Cargo is unavailable, it uses `docker compose up --build`.
|
||||
|
||||
## Docker
|
||||
## Funkcje notatek workspace
|
||||
|
||||
```bash
|
||||
cp .env.docker.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
- real-time collaborative editing over WebSocket,
|
||||
- nickname remembered in `localStorage`,
|
||||
- change authors in history,
|
||||
- line numbering enabled by default with a persistent toggle,
|
||||
- owner color next to each line,
|
||||
- upload images and files to `data/files/pads/<id>_<token>/` lub `data/files/notes/<id>_<token>/`,
|
||||
- automatic Markdown link insertion after upload,
|
||||
- Markdown i diagramy Mermaid,
|
||||
- history with snippets, previews, and version restore.
|
||||
|
||||
Baza znajduje się w wolumenie `rustpad_data`.
|
||||
## Dane
|
||||
|
||||
## Adresy
|
||||
- SQLite: `data/db/rustpad.db`,
|
||||
- files: `data/files/pads/<id>_<token>/` i `data/files/notes/<id>_<token>/`; the public URL has the form `/f/<token>/<nazwa>`.
|
||||
|
||||
```text
|
||||
/p/notatka?view=split&mode=markdown
|
||||
/w/workspace
|
||||
/w/workspace/n/notatka?view=split&mode=markdown
|
||||
```
|
||||
In Docker, both directories are located under `/data`.
|
||||
|
||||
## Frontend
|
||||
## Publishing a note as a page
|
||||
|
||||
- `home.js` — tworzenie szybkiej notatki i workspace,
|
||||
- `pad.js` — samodzielna notatka,
|
||||
- `workspace.js` — lista i tworzenie notatek w workspace,
|
||||
- `note.js` — notatka należąca do workspace,
|
||||
- `url-state.js` — stan widoku w URL,
|
||||
- `clipboard.js` — kopiowanie pełnego adresu,
|
||||
- `socket.js` — połączenia WebSocket,
|
||||
- `markdown.js` — renderowanie Markdown,
|
||||
- `editor-format.js` — formatowanie tekstu,
|
||||
- `api.js` — komunikacja HTTP,
|
||||
- `session.js` — hasła workspace w `sessionStorage`.
|
||||
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` address, copies it to the clipboard, and opens it in a new tab. The page displays the current note content and renders Markdown, images, links, and Mermaid. Publishing a protected note requires the password, but the published link itself is public.
|
||||
|
||||
## Dane istniejące
|
||||
## Limit uploadu
|
||||
|
||||
Tabele `pads` i `revisions` pozostają aktywne. Workspace są dodatkiem, nie zamiennikiem zwykłych notatek. Przed aktualizacją wykonaj kopię `rustpad.db`.
|
||||
The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.env`, np. `UPLOAD_MAX_SIZE_MB=50`. The default is 20 MB. After changing it, restart the project with `./dev.sh`.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
mkdir -p data/db data/files
|
||||
|
||||
export APP_HOST="${APP_HOST:-0.0.0.0}"
|
||||
export APP_PORT="${APP_PORT:-3000}"
|
||||
export DATABASE_URL="${DATABASE_URL:-sqlite://$(pwd)/data/db/rustpad.db?mode=rwc}"
|
||||
export FILES_DIR="${FILES_DIR:-$(pwd)/data/files}"
|
||||
export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}"
|
||||
export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}"
|
||||
export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
|
||||
# A new value on every run prevents stale HTML/JS cache issues.
|
||||
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
||||
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
exec cargo run
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
export IMAGE_TAG="${IMAGE_TAG:-dev}"
|
||||
exec docker compose up --build --force-recreate --remove-orphans
|
||||
else
|
||||
echo "Brak cargo i docker. Zainstaluj Rust 1.85+ albo Docker." >&2
|
||||
exit 1
|
||||
fi
|
||||
+5
-6
@@ -8,15 +8,14 @@ services:
|
||||
environment:
|
||||
APP_HOST: ${APP_HOST:-0.0.0.0}
|
||||
APP_PORT: ${APP_PORT:-3000}
|
||||
DATABASE_URL: ${DATABASE_URL:-sqlite:///data/rustpad.db?mode=rwc}
|
||||
DATABASE_URL: ${DATABASE_URL:-sqlite:///data/db/rustpad.db?mode=rwc}
|
||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
||||
STATIC_DIR: ${STATIC_DIR:-/app/static}
|
||||
ASSET_VERSION: ${ASSET_VERSION:-0.0.1}
|
||||
FILES_DIR: ${FILES_DIR:-/data/files}
|
||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
|
||||
ASSET_VERSION: ${ASSET_VERSION:-dev}
|
||||
RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info}
|
||||
ports:
|
||||
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
|
||||
volumes:
|
||||
- rustpad_data:/data
|
||||
|
||||
volumes:
|
||||
rustpad_data:
|
||||
- ./data:/data
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git_command(args, repo_path: Path) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_files_to_archive(repo_path: Path) -> list[str]:
|
||||
output = run_git_command(
|
||||
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
repo_path,
|
||||
)
|
||||
files = output.decode("utf-8", errors="surrogateescape").split("\0")
|
||||
return [f for f in files if f]
|
||||
|
||||
|
||||
def make_zip(repo_path: Path, output_zip: Path) -> None:
|
||||
files = get_files_to_archive(repo_path)
|
||||
|
||||
output_zip = output_zip.resolve()
|
||||
if output_zip.exists():
|
||||
output_zip.unlink()
|
||||
|
||||
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel_path in files:
|
||||
abs_path = repo_path / rel_path
|
||||
|
||||
if not abs_path.exists():
|
||||
continue
|
||||
|
||||
if abs_path.resolve() == output_zip:
|
||||
continue
|
||||
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
print(f"Created: {output_zip}")
|
||||
print(f"Added files: {len(files)}")
|
||||
|
||||
|
||||
def main():
|
||||
repo_path = Path.cwd()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
output_zip = Path(sys.argv[1])
|
||||
else:
|
||||
output_zip = repo_path / f"{repo_path.name}.zip"
|
||||
|
||||
try:
|
||||
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: this directory is not a Git repository.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
make_zip(repo_path, output_zip)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS note_revisions (
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC);
|
||||
|
||||
-- Zachowanie danych z wersji 0.4.x: stara notatka staje się workspace z jedną notatką.
|
||||
-- Preserve data from version 0.4.x: the old note becomes a workspace with one note.
|
||||
INSERT OR IGNORE INTO workspaces (id, slug, title, password_hash, created_at, updated_at)
|
||||
SELECT id, slug, title, password_hash, created_at, updated_at FROM pads;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE notes ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE note_revisions ADD COLUMN author TEXT;
|
||||
ALTER TABLE note_revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE revisions ADD COLUMN author TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE pads ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE published_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
pad_id INTEGER UNIQUE REFERENCES pads(id) ON DELETE CASCADE,
|
||||
note_id INTEGER UNIQUE REFERENCES notes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL))
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE pads ADD COLUMN file_token TEXT;
|
||||
ALTER TABLE notes ADD COLUMN file_token TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pads_file_token ON pads(file_token) WHERE file_token IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_file_token ON notes(file_token) WHERE file_token IS NOT NULL;
|
||||
BIN
Binary file not shown.
+222
-20
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
extract::{Multipart, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
@@ -17,6 +17,18 @@ const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublishResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicPageResponse {
|
||||
title: String,
|
||||
content: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWorkspaceRequest {
|
||||
name: String,
|
||||
@@ -89,7 +101,7 @@ pub async fn create_workspace(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreateWorkspaceRequest>,
|
||||
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa workspace")?;
|
||||
let title = validate_name(&payload.name, "Workspace name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let slug = unique_workspace_slug(&state, title).await?;
|
||||
|
||||
@@ -144,10 +156,10 @@ pub async fn create_note(
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
@@ -211,11 +223,13 @@ pub async fn restore(
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) =
|
||||
db::save_revision(&state.db, note.id, workspace.id, &content).await?;
|
||||
db::save_revision(&state.db, note.id, workspace.id, &content, Some("restore"), "[]").await?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
author: Some("restore".into()),
|
||||
owner_map: "[]".into(),
|
||||
};
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -262,7 +276,7 @@ fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH {
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"{field} musi mieć od 1 do {MAX_NAME_LENGTH} znaków"
|
||||
"{field} must contain between 1 and {MAX_NAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
@@ -275,7 +289,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
let length = password.chars().count();
|
||||
if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) {
|
||||
return Err(ApiError::bad_request(
|
||||
"Hasło musi mieć od 8 do 128 znaków",
|
||||
"Password must contain between 8 and 128 characters",
|
||||
));
|
||||
}
|
||||
Ok(Some(password))
|
||||
@@ -284,7 +298,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|
||||
@@ -299,7 +313,7 @@ async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<Strin
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
async fn unique_note_slug(
|
||||
@@ -319,7 +333,7 @@ async fn unique_note_slug(
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
@@ -349,11 +363,11 @@ pub async fn create_pad(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreatePadRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
db::create_pad(&state.db, &slug, title, password).await?;
|
||||
@@ -382,6 +396,40 @@ pub async fn pad_info(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn publish_pad_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_pad(&state.db, pad.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn publish_note_page(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_note(&state.db, note.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn public_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<PublicPageResponse>, ApiError> {
|
||||
let page = db::find_published_page(&state.db, &token)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(Json(PublicPageResponse {
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
updated_at: db::normalize_timestamp(&page.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn pad_history(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
@@ -405,11 +453,19 @@ pub async fn pad_restore(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content).await?;
|
||||
let owner_map: Option<String> = sqlx::query_scalar("SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?")
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
author: Some("restore".into()),
|
||||
owner_map,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -439,7 +495,143 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiE
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn upload_pad_file(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
if bytes.len() > state.upload_max_size_bytes {
|
||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
||||
}
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let pad = authorized_pad(&state, &slug, password.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::pad_file_token(&state.db, pad.id).await?;
|
||||
let directory = format!("{}_{}", pad.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("pads").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default();
|
||||
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)})))
|
||||
}
|
||||
|
||||
pub async fn upload_note_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
if bytes.len() > state.upload_max_size_bytes {
|
||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
||||
}
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::note_file_token(&state.db, note.id).await?;
|
||||
let directory = format!("{}_{}", note.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("notes").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default();
|
||||
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)})))
|
||||
}
|
||||
|
||||
|
||||
pub async fn download_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((token, filename)): Path<(String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
serve_token_file(&state, &token, &filename).await
|
||||
}
|
||||
|
||||
pub async fn download_legacy_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((directory, filename)): Path<(String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let Some((id_part, token)) = directory.split_once('_') else {
|
||||
return Err(ApiError::not_found_file());
|
||||
};
|
||||
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
|
||||
let owner = db::find_file_owner(&state.db, token).await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
if owner.id != id {
|
||||
return Err(ApiError::not_found_file());
|
||||
}
|
||||
serve_token_file(&state, token, &filename).await
|
||||
}
|
||||
|
||||
async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> Result<Response, ApiError> {
|
||||
let safe = sanitize_filename(filename);
|
||||
if safe != filename {
|
||||
return Err(ApiError::not_found_file());
|
||||
}
|
||||
let owner = db::find_file_owner(&state.db, token).await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
let kind = match owner.kind {
|
||||
db::FileOwnerKind::Pad => "pads",
|
||||
db::FileOwnerKind::Note => "notes",
|
||||
};
|
||||
let directory = format!("{}_{}", owner.id, token);
|
||||
let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe);
|
||||
let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe);
|
||||
let path = if canonical.is_file() { canonical } else { legacy };
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|_| ApiError::not_found_file())?;
|
||||
let mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
||||
let mut response = bytes.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn sanitize_filename(value: &str) -> String {
|
||||
let name = std::path::Path::new(value).file_name().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let clean: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect();
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() }
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
@@ -454,28 +646,38 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn payload_too_large(max_bytes: usize) -> Self {
|
||||
let max_mb = max_bytes / (1024 * 1024);
|
||||
Self {
|
||||
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
message: format!("The file may be at most {max_mb} MB"),
|
||||
}
|
||||
}
|
||||
fn not_found_file() -> Self {
|
||||
Self { status: StatusCode::NOT_FOUND, message: "File not found".into() }
|
||||
}
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: "Nieprawidłowe hasło".into(),
|
||||
message: "Invalid password".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_workspace() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono workspace".into(),
|
||||
message: "Workspace not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_note() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono notatki".into(),
|
||||
message: "Note not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_revision() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono wersji".into(),
|
||||
message: "Revision not found".into(),
|
||||
}
|
||||
}
|
||||
fn internal(message: &str) -> Self {
|
||||
@@ -489,7 +691,7 @@ impl ApiError {
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
tracing::error!(%error, "database error");
|
||||
Self::internal("Błąd bazy danych")
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
-21
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{DefaultBodyLimit, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
@@ -9,17 +9,23 @@ use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
|
||||
use crate::{api, db, state::SharedState, websocket};
|
||||
|
||||
pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/p/{slug}", get(pad))
|
||||
.route("/s/{token}", get(public_page))
|
||||
.route("/w/{workspace_slug}", get(workspace))
|
||||
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
||||
.route("/health", get(health))
|
||||
.route("/f/{token}/{filename}", get(api::download_file))
|
||||
.route("/files/{directory}/{filename}", get(api::download_legacy_file))
|
||||
.route("/api/public/{token}", get(api::public_page))
|
||||
.route("/api/pads", post(api::create_pad))
|
||||
.route("/api/pads/{slug}", get(api::pad_info))
|
||||
.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/workspaces", post(api::create_workspace))
|
||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
|
||||
@@ -28,6 +34,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||
get(api::note_info),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
|
||||
post(api::publish_note_page),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
|
||||
post(api::history),
|
||||
@@ -36,6 +46,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
|
||||
post(api::restore),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
|
||||
post(api::upload_note_file),
|
||||
)
|
||||
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||
.route(
|
||||
"/ws/{workspace_slug}/{note_slug}",
|
||||
@@ -43,6 +57,7 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
)
|
||||
.nest_service("/assets", ServeDir::new(static_dir))
|
||||
.fallback(not_found)
|
||||
.layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024)))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -64,10 +79,10 @@ async fn pad(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
"Note not found",
|
||||
"This note does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -77,6 +92,28 @@ async fn pad(
|
||||
}
|
||||
}
|
||||
|
||||
async fn public_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
match db::find_published_page(&state.db, &token).await {
|
||||
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Published page not found",
|
||||
"The link is invalid or the published page has been removed.",
|
||||
"/",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %token, "failed to load published page");
|
||||
internal_error(&state.asset_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn workspace(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
@@ -89,10 +126,10 @@ async fn workspace(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Ten workspace nie istnieje albo został usunięty.",
|
||||
"Workspace not found",
|
||||
"This workspace does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -112,10 +149,10 @@ async fn note(
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Workspace tej notatki nie istnieje albo został usunięty.",
|
||||
"Workspace not found",
|
||||
"The workspace for this note does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
);
|
||||
}
|
||||
@@ -130,10 +167,10 @@ async fn note(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
"Note not found",
|
||||
"This note does not exist or has been deleted.",
|
||||
&format!("/w/{workspace_slug}"),
|
||||
"Wróć do workspace",
|
||||
"Back do workspace",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -147,10 +184,10 @@ async fn not_found(State(state): State<SharedState>) -> Response {
|
||||
error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono strony",
|
||||
"Sprawdź adres albo wróć na stronę główną.",
|
||||
"Page not found",
|
||||
"Check the address or return to the home page.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
)
|
||||
}
|
||||
@@ -159,10 +196,10 @@ fn internal_error(asset_version: &str) -> Response {
|
||||
error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"500",
|
||||
"Błąd serwera",
|
||||
"Nie udało się wczytać strony. Spróbuj ponownie za chwilę.",
|
||||
"Server error",
|
||||
"The page could not be loaded. Please try again shortly.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
asset_version,
|
||||
)
|
||||
}
|
||||
@@ -199,7 +236,7 @@ fn versioned_html(template: &str, asset_version: &str) -> Response {
|
||||
fn no_store(response: &mut Response) {
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -7,6 +7,8 @@ pub struct Config {
|
||||
pub database_url: String,
|
||||
pub database_max_connections: u32,
|
||||
pub static_dir: String,
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub asset_version: String,
|
||||
}
|
||||
|
||||
@@ -15,13 +17,21 @@ impl Config {
|
||||
let host = env_var("APP_HOST", "127.0.0.1").parse()?;
|
||||
let port = env_var("APP_PORT", "3000").parse()?;
|
||||
let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||
let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
if upload_max_size_mb == 0 {
|
||||
return Err("UPLOAD_MAX_SIZE_MB > 0".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"),
|
||||
database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
|
||||
database_max_connections,
|
||||
static_dir: env_var("STATIC_DIR", "static"),
|
||||
files_dir: env_var("FILES_DIR", "data/files"),
|
||||
upload_max_size_bytes: upload_max_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or("UPLOAD_MAX_SIZE_MB to big")?,
|
||||
asset_version: env::var("ASSET_VERSION")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct Note {
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
@@ -34,6 +35,8 @@ pub struct Revision {
|
||||
pub id: i64,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub author: Option<String>,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||
@@ -86,7 +89,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
|
||||
|
||||
pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC",
|
||||
"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",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
@@ -99,7 +102,7 @@ pub async fn find_note(
|
||||
slug: &str,
|
||||
) -> Result<Option<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? AND slug = ?",
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
@@ -123,7 +126,7 @@ pub async fn create_note(
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?",
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
@@ -135,10 +138,13 @@ pub async fn save_revision(
|
||||
note_id: i64,
|
||||
workspace_id: i64,
|
||||
content: &str,
|
||||
author: Option<&str>,
|
||||
owner_map: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(owner_map)
|
||||
.bind(note_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -146,9 +152,11 @@ pub async fn save_revision(
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)")
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
||||
.bind(note_id)
|
||||
.bind(content)
|
||||
.bind(author)
|
||||
.bind(owner_map)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
||||
@@ -161,7 +169,7 @@ pub async fn save_revision(
|
||||
|
||||
pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
||||
"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(note_id)
|
||||
.fetch_all(pool)
|
||||
@@ -202,11 +210,12 @@ pub struct Pad {
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE slug = ?",
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
@@ -230,7 +239,7 @@ pub async fn create_pad(
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?",
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
@@ -256,16 +265,21 @@ pub async fn save_pad_revision(
|
||||
pool: &SqlitePool,
|
||||
pad_id: i64,
|
||||
content: &str,
|
||||
author: Option<&str>,
|
||||
owner_map: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(owner_map)
|
||||
.bind(pad_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)")
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
||||
.bind(pad_id)
|
||||
.bind(content)
|
||||
.bind(author)
|
||||
.bind(owner_map)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
||||
@@ -281,9 +295,135 @@ pub async fn list_pad_revisions(
|
||||
pad_id: i64,
|
||||
) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
||||
"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(pad_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PublishedPage {
|
||||
pub token: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
let token = random_suffix(18);
|
||||
sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)")
|
||||
.bind(&token)
|
||||
.bind(pad_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
let token = random_suffix(18);
|
||||
sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)")
|
||||
.bind(&token)
|
||||
.bind(note_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> {
|
||||
sqlx::query_as::<_, PublishedPage>(
|
||||
"SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let token = format!("p_{}", random_suffix(24));
|
||||
sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
||||
.bind(&token)
|
||||
.bind(pad_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let token = format!("n_{}", random_suffix(24));
|
||||
sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
||||
.bind(&token)
|
||||
.bind(note_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FileOwnerKind {
|
||||
Pad,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FileOwner {
|
||||
pub kind: FileOwnerKind,
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result<Option<FileOwner>, sqlx::Error> {
|
||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id }));
|
||||
}
|
||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id }));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
+15
-2
@@ -20,14 +20,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_tracing();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; }
|
||||
}
|
||||
let db = SqlitePoolOptions::new()
|
||||
.max_connections(config.database_max_connections)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
sqlx::migrate!().run(&db).await?;
|
||||
|
||||
let state = Arc::new(AppState::new(db, config.asset_version.clone()));
|
||||
let app = app::router(state, &config.static_dir);
|
||||
std::fs::create_dir_all(&config.files_dir)?;
|
||||
let state = Arc::new(AppState::new(
|
||||
db,
|
||||
config.asset_version.clone(),
|
||||
config.files_dir.clone(),
|
||||
config.upload_max_size_bytes,
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
&config.static_dir,
|
||||
config.upload_max_size_bytes,
|
||||
);
|
||||
let address = SocketAddr::new(config.host, config.port);
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
|
||||
+8
-18
@@ -1,5 +1,4 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
@@ -10,40 +9,31 @@ pub struct NoteUpdate {
|
||||
pub content: String,
|
||||
pub revision_id: i64,
|
||||
pub updated_at: String,
|
||||
pub author: Option<String>,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub asset_version: String,
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: SqlitePool, asset_version: String) -> Self {
|
||||
Self {
|
||||
db,
|
||||
asset_version,
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
}
|
||||
pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self {
|
||||
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||
if let Some(sender) = self.channels.read().await.get(&key) {
|
||||
return sender.clone();
|
||||
}
|
||||
|
||||
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
||||
let mut channels = self.channels.write().await;
|
||||
channels
|
||||
.entry(key)
|
||||
.or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0)
|
||||
.clone()
|
||||
channels.entry(key).or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0).clone()
|
||||
}
|
||||
|
||||
pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender<NoteUpdate> {
|
||||
self.channel_for_key(format!("workspace:{workspace_slug}/{note_slug}")).await
|
||||
}
|
||||
|
||||
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<NoteUpdate> {
|
||||
self.channel_for_key(format!("pad:{slug}")).await
|
||||
}
|
||||
|
||||
+82
-259
@@ -1,292 +1,115 @@
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Path, State, WebSocketUpgrade,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, response::Response};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{
|
||||
db,
|
||||
state::{NoteUpdate, SharedState},
|
||||
};
|
||||
use crate::{db, state::{NoteUpdate, SharedState}};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientMessage {
|
||||
Authenticate { password: Option<String> },
|
||||
Update { content: String },
|
||||
Authenticate { password: Option<String>, nickname: Option<String> },
|
||||
Update { content: String, owner_map: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerMessage {
|
||||
Authenticated {
|
||||
workspace_title: String,
|
||||
note_title: String,
|
||||
content: String,
|
||||
},
|
||||
Document {
|
||||
content: String,
|
||||
revision_id: i64,
|
||||
updated_at: String,
|
||||
},
|
||||
Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub async fn upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Path<(String, String)>, State(state): State<SharedState>) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
workspace_slug: String,
|
||||
note_slug: String,
|
||||
) {
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
let _ = send_error(&mut socket, "Nie znaleziono workspace").await;
|
||||
return;
|
||||
};
|
||||
let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
let _ = send_error(&mut socket, "Nie znaleziono notatki").await;
|
||||
return;
|
||||
};
|
||||
|
||||
let password = match socket.recv().await {
|
||||
async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) {
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Workspace not found").await; return; };
|
||||
let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Note not found").await; return; };
|
||||
let (password, nickname) = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate { password }) => password,
|
||||
_ => {
|
||||
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
Ok(ClientMessage::Authenticate { password, nickname }) => (password, clean_nickname(nickname)),
|
||||
_ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; }
|
||||
}, _ => return
|
||||
};
|
||||
|
||||
if !db::verify_workspace_password(&workspace, password.as_deref()) {
|
||||
let _ = send_error(&mut socket, "Nieprawidłowe hasło").await;
|
||||
return;
|
||||
}
|
||||
|
||||
if send(
|
||||
&mut socket,
|
||||
&ServerMessage::Authenticated {
|
||||
workspace_title: workspace.title.clone(),
|
||||
note_title: note.title.clone(),
|
||||
content: note.content.clone(),
|
||||
if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; }
|
||||
if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;}
|
||||
let channel=state.note_channel(&workspace_slug,¬e_slug).await;
|
||||
let mut updates=channel.subscribe();
|
||||
let (mut sender,mut receiver)=socket.split();
|
||||
loop { tokio::select! {
|
||||
incoming=receiver.next()=>match incoming {
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Update{content,owner_map})=>{
|
||||
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
|
||||
Ok((revision_id,updated_at))=>{let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});}
|
||||
Err(error)=>warn!(%error,"failed to save revision"),
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_slug).await;
|
||||
let mut updates = channel.subscribe();
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
incoming = receiver.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Update { content }) => {
|
||||
if content.len() > 2_000_000 {
|
||||
let _ = send_split(&mut sender, &ServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await;
|
||||
continue;
|
||||
}
|
||||
match db::save_revision(&state.db, note.id, workspace.id, &content).await {
|
||||
Ok((revision_id, updated_at)) => {
|
||||
let _ = channel.send(NoteUpdate { content, revision_id, updated_at });
|
||||
}
|
||||
Err(error) => warn!(%error, "failed to save revision"),
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate { .. }) => {}
|
||||
Err(error) => warn!(%error, "invalid WebSocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => {
|
||||
debug!(%error, "WebSocket receive error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
update = updates.recv() => match update {
|
||||
Ok(update) => {
|
||||
if send_split(&mut sender, &ServerMessage::Document {
|
||||
content: update.content,
|
||||
revision_id: update.revision_id,
|
||||
updated_at: update.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
if let Ok(Some(current)) = db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||
if send_split(&mut sender, &ServerMessage::Document {
|
||||
content: current.content,
|
||||
revision_id: 0,
|
||||
updated_at: current.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
update=updates.recv()=>match update {
|
||||
Ok(update)=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> {
|
||||
send(socket, &ServerMessage::Error { message: message.into() }).await
|
||||
}
|
||||
|
||||
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing server message cannot fail");
|
||||
socket.send(Message::Text(payload.into())).await
|
||||
}
|
||||
|
||||
async fn send_split(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &ServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing server message cannot fail");
|
||||
sender.send(Message::Text(payload.into())).await
|
||||
}}
|
||||
}
|
||||
fn clean_nickname(value: Option<String>)->Option<String>{value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())}
|
||||
async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error>{send(socket,&ServerMessage::Error{message:message.into()}).await}
|
||||
async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
async fn send_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&ServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[serde(tag="type",rename_all="snake_case")]
|
||||
enum PadServerMessage {
|
||||
Authenticated { title: String, content: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String },
|
||||
Authenticated { title: String, content: String, owner_map: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub async fn upgrade_pad(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(slug): Path<String>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
|
||||
pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state):State<SharedState>)->Response{
|
||||
ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug))
|
||||
}
|
||||
|
||||
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
|
||||
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nie znaleziono notatki".into() }).await;
|
||||
return;
|
||||
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
||||
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;};
|
||||
let (password,nickname)=match socket.recv().await{
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||
Ok(ClientMessage::Authenticate{password,nickname})=>(password,clean_nickname(nickname)),
|
||||
_=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;}
|
||||
},_=>return
|
||||
};
|
||||
|
||||
let password = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate { password }) => password,
|
||||
_ => {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Wymagane uwierzytelnienie".into() }).await;
|
||||
return;
|
||||
}
|
||||
if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;}
|
||||
if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;}
|
||||
let channel=state.pad_channel(&slug).await;
|
||||
let mut updates=channel.subscribe();
|
||||
let(mut sender,mut receiver)=socket.split();
|
||||
loop{tokio::select!{
|
||||
incoming=receiver.next()=>match incoming{
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||
Ok(ClientMessage::Update{content,owner_map})=>{
|
||||
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
|
||||
let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate{..})=>{},
|
||||
Err(error)=>warn!(%error,"invalid pad websocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_)))|None=>break,
|
||||
Some(Ok(_))=>{},
|
||||
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if !db::verify_pad_password(&pad, password.as_deref()) {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nieprawidłowe hasło".into() }).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if send_pad(&mut socket, &PadServerMessage::Authenticated {
|
||||
title: pad.title.clone(),
|
||||
content: pad.content.clone(),
|
||||
}).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = state.pad_channel(&slug).await;
|
||||
let mut updates = channel.subscribe();
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
incoming = receiver.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Update { content }) => {
|
||||
if content.len() > 2_000_000 {
|
||||
let _ = send_pad_split(&mut sender, &PadServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await;
|
||||
continue;
|
||||
}
|
||||
match db::save_pad_revision(&state.db, pad.id, &content).await {
|
||||
Ok((revision_id, updated_at)) => {
|
||||
let _ = channel.send(NoteUpdate { content, revision_id, updated_at });
|
||||
}
|
||||
Err(error) => warn!(%error, "failed to save pad revision"),
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate { .. }) => {}
|
||||
Err(error) => warn!(%error, "invalid pad WebSocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => {
|
||||
debug!(%error, "pad WebSocket receive error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
update = updates.recv() => match update {
|
||||
Ok(update) => {
|
||||
if send_pad_split(&mut sender, &PadServerMessage::Document {
|
||||
content: update.content,
|
||||
revision_id: update.revision_id,
|
||||
updated_at: update.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
if let Ok(Some(current)) = db::find_pad(&state.db, &slug).await {
|
||||
if send_pad_split(&mut sender, &PadServerMessage::Document {
|
||||
content: current.content,
|
||||
revision_id: 0,
|
||||
updated_at: current.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
update=updates.recv()=>match update{
|
||||
Ok(u)=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail");
|
||||
socket.send(Message::Text(payload.into())).await
|
||||
}
|
||||
|
||||
async fn send_pad_split(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &PadServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail");
|
||||
sender.send(Message::Text(payload.into())).await
|
||||
}}
|
||||
}
|
||||
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
async fn send_pad_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&PadServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -16,7 +16,7 @@
|
||||
<p>__ERROR_MESSAGE__</p>
|
||||
<div class="error-actions">
|
||||
<a class="primary-button inline-button" href="__PRIMARY_URL__">__PRIMARY_LABEL__</a>
|
||||
<button class="secondary-button inline-button" type="button" onclick="history.back()">Wstecz</button>
|
||||
<button class="secondary-button inline-button" type="button" onclick="history.back()">Back</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+20
-20
@@ -1,60 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<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>RustPad</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__">
|
||||
<script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
|
||||
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header"><a class="brand" href="/">RustPad</a></header>
|
||||
<main class="home-layout home-layout--wide">
|
||||
<section class="home-intro">
|
||||
<h1>Nowa przestrzeń</h1>
|
||||
<p>Utwórz szybką notatkę albo workspace z wieloma notatkami.</p>
|
||||
<h1>New workspace</h1>
|
||||
<p>Create a quick note or a workspace with multiple notes.</p>
|
||||
</section>
|
||||
|
||||
<div class="create-grid">
|
||||
<section class="panel create-panel">
|
||||
<div class="create-panel__heading">
|
||||
<h2>Notatka</h2>
|
||||
<p>Jeden dokument pod własnym linkiem.</p>
|
||||
<h2>Note</h2>
|
||||
<p>A single document with its own link.</p>
|
||||
</div>
|
||||
<form id="pad-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="pad-name">Nazwa notatki</label>
|
||||
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Notatki ze spotkania">
|
||||
<div class="field-meta"><span id="pad-slug-preview">/p/notatki-ze-spotkania</span><span id="pad-name-count">0/80</span></div>
|
||||
<label for="pad-name">Note name</label>
|
||||
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes">
|
||||
<div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span id="pad-name-count">0/80</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label-row"><label for="pad-password">Hasło</label><span>opcjonalne, min. 8 znaków</span></div>
|
||||
<div class="password-input"><input id="pad-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Hasło notatki"><button class="text-button password-toggle" type="button" data-target="pad-password">Pokaż</button></div>
|
||||
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span></div>
|
||||
<div class="password-input"><input id="pad-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Note password"><button class="text-button password-toggle" type="button" data-target="pad-password">Show</button></div>
|
||||
</div>
|
||||
<p id="pad-error" class="form-message error" role="alert"></p>
|
||||
<button id="pad-button" class="primary-button" type="submit">Utwórz notatkę</button>
|
||||
<button id="pad-button" class="primary-button" type="submit">Create note</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel create-panel">
|
||||
<div class="create-panel__heading">
|
||||
<h2>Workspace</h2>
|
||||
<p>Przestrzeń z listą wielu notatek.</p>
|
||||
<p>A workspace containing multiple notes.</p>
|
||||
</div>
|
||||
<form id="workspace-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="workspace-name">Nazwa workspace</label>
|
||||
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="Mój projekt">
|
||||
<div class="field-meta"><span id="workspace-slug-preview">/w/moj-projekt</span><span id="workspace-name-count">0/80</span></div>
|
||||
<small>Krótka lub zajęta nazwa otrzyma losowy sufiks.</small>
|
||||
<label for="workspace-name">Workspace name</label>
|
||||
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project">
|
||||
<div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span id="workspace-name-count">0/80</span></div>
|
||||
<small>A short or unavailable name will receive a random suffix.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label-row"><label for="workspace-password">Hasło</label><span>opcjonalne, min. 8 znaków</span></div>
|
||||
<div class="password-input"><input id="workspace-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Hasło workspace"><button class="text-button password-toggle" type="button" data-target="workspace-password">Pokaż</button></div>
|
||||
<div class="label-row"><label for="workspace-password">Password</label><span>optional, min. 8 characters</span></div>
|
||||
<div class="password-input"><input id="workspace-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Workspace password"><button class="text-button password-toggle" type="button" data-target="workspace-password">Show</button></div>
|
||||
</div>
|
||||
<p id="workspace-error" class="form-message error" role="alert"></p>
|
||||
<button id="workspace-button" class="primary-button" type="submit">Utwórz workspace</button>
|
||||
<button id="workspace-button" class="primary-button" type="submit">Create workspace</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+6
-10
@@ -2,18 +2,14 @@ export async function api(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Błąd ${response.status}`);
|
||||
if (!response.ok) throw new Error(data.error || `Error ${response.status}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") throw new Error("Przekroczono czas odpowiedzi serwera");
|
||||
if (error.name === "AbortError") throw new Error("Timed out");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ export async function copyText(text) {
|
||||
input.select();
|
||||
const copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
if (!copied) throw new Error("Nie udało się skopiować linku");
|
||||
if (!copied) throw new Error("Failed to copy the link");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function applyFormat(editor, format) {
|
||||
if (format === "bullet") prefix("- ");
|
||||
if (format === "number") prefix((index) => `${index + 1}. `);
|
||||
if (format === "quote") prefix("> ");
|
||||
if (format === "link") wrap("[", "](https://)", "opis linku");
|
||||
if (format === "link") wrap("[", "](https://)", "description");
|
||||
editor.focus();
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
import { api } from "./api.js?v=0.6.0";
|
||||
import { api } from "@rustpad/api";
|
||||
|
||||
function slugify(value, fallback) {
|
||||
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
|
||||
@@ -21,7 +21,7 @@ function setBusy(button, busy, idleText, busyText) {
|
||||
button.textContent = busy ? busyText : idleText;
|
||||
}
|
||||
|
||||
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "notatka");
|
||||
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note");
|
||||
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
|
||||
|
||||
document.querySelectorAll(".password-toggle").forEach((button) => {
|
||||
@@ -29,7 +29,7 @@ document.querySelectorAll(".password-toggle").forEach((button) => {
|
||||
const input = document.getElementById(button.dataset.target);
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
button.textContent = show ? "Ukryj" : "Pokaż";
|
||||
button.textContent = show ? "Hide" : "Show";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
|
||||
const button = document.querySelector("#pad-button");
|
||||
const error = document.querySelector("#pad-error");
|
||||
error.textContent = "";
|
||||
setBusy(button, true, "Utwórz notatkę", "Tworzenie…");
|
||||
setBusy(button, true, "Create note", "Creating…");
|
||||
try {
|
||||
const payload = { name: name.value.trim() };
|
||||
if (password.value) payload.password = password.value;
|
||||
@@ -50,7 +50,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
|
||||
} catch (requestError) {
|
||||
error.textContent = requestError.message;
|
||||
} finally {
|
||||
setBusy(button, false, "Utwórz notatkę", "Tworzenie…");
|
||||
setBusy(button, false, "Create note", "Creating…");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
const button = document.querySelector("#workspace-button");
|
||||
const error = document.querySelector("#workspace-error");
|
||||
error.textContent = "";
|
||||
setBusy(button, true, "Utwórz workspace", "Tworzenie…");
|
||||
setBusy(button, true, "Create workspace", "Creating…");
|
||||
try {
|
||||
const payload = { name: name.value.trim() };
|
||||
if (password.value) payload.password = password.value;
|
||||
@@ -71,6 +71,6 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
} catch (requestError) {
|
||||
error.textContent = requestError.message;
|
||||
} finally {
|
||||
setBusy(button, false, "Utwórz workspace", "Tworzenie…");
|
||||
setBusy(button, false, "Create workspace", "Creating…");
|
||||
}
|
||||
});
|
||||
|
||||
+36
-11
@@ -1,26 +1,51 @@
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
return String(value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
const url = String(value).trim();
|
||||
if (/^(https?:\/\/|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url);
|
||||
return "#";
|
||||
}
|
||||
|
||||
function inline(value) {
|
||||
return escapeHtml(value)
|
||||
const tokens = [];
|
||||
let html = escapeHtml(value);
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
||||
const token = `\u0000IMG${tokens.length}\u0000`;
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
tokens.push(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
|
||||
return token;
|
||||
});
|
||||
html = html
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return `<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`;
|
||||
});
|
||||
return html.replace(/\u0000IMG(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
|
||||
}
|
||||
|
||||
export function renderMarkdown(source) {
|
||||
let html = "", inCode = false, list = null;
|
||||
let html = "", inCode = false, language = "", code = [], list = null;
|
||||
const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
|
||||
for (const line of source.split("\n")) {
|
||||
if (line.startsWith("```")) { closeList(); html += inCode ? "</code></pre>" : "<pre><code>"; inCode = !inCode; continue; }
|
||||
if (inCode) { html += `${escapeHtml(line)}\n`; continue; }
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
|
||||
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
const closeCode = () => {
|
||||
const body = escapeHtml(code.join("\n"));
|
||||
html += language.toLowerCase() === "mermaid"
|
||||
? `<div class="mermaid">${body}</div>`
|
||||
: `<pre><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
|
||||
code = []; language = "";
|
||||
};
|
||||
for (const line of String(source).split("\n")) {
|
||||
if (line.startsWith("```")) { closeList(); if (inCode) closeCode(); else language = line.slice(3).trim(); inCode = !inCode; continue; }
|
||||
if (inCode) { code.push(line); continue; }
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/), ul = line.match(/^\s*[-*+]\s+(.+)$/), ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (heading) { closeList(); const n = heading[1].length; html += `<h${n}>${inline(heading[2])}</h${n}>`; }
|
||||
else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `<li>${inline((ul || ol)[1])}</li>`; }
|
||||
else { closeList(); if (/^---+$/.test(line)) html += "<hr>"; else if (line.startsWith("> ")) html += `<blockquote>${inline(line.slice(2))}</blockquote>`; else if (line.trim()) html += `<p>${inline(line)}</p>`; else html += "<br>"; }
|
||||
}
|
||||
closeList(); if (inCode) html += "</code></pre>"; return html;
|
||||
closeList(); if (inCode) closeCode(); return html;
|
||||
}
|
||||
|
||||
+34
-51
@@ -1,53 +1,36 @@
|
||||
import { api } from "./api.js?v=0.6.0";
|
||||
import { copyText } from "./clipboard.js?v=0.6.0";
|
||||
import { applyFormat } from "./editor-format.js?v=0.6.0";
|
||||
import { renderMarkdown } from "./markdown.js?v=0.6.0";
|
||||
import { getPassword, setPassword } from "./session.js?v=0.6.0";
|
||||
import { NoteSocket } from "./socket.js?v=0.6.0";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0";
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session";
|
||||
import { NoteSocket } from "@rustpad/socket";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const workspaceSlug = parts[1], noteSlug = parts[3];
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog");
|
||||
let password = getPassword(workspaceSlug), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState();
|
||||
|
||||
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); }
|
||||
function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
|
||||
function updateAddressLabel() { document.querySelector("#note-url").textContent = `${location.pathname}${location.search}`; }
|
||||
function render() {
|
||||
if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Podgląd Markdown"; }
|
||||
else { preview.classList.add("preview--raw"); preview.textContent = editor.value; document.querySelector("#preview-label").textContent = "Tekst źródłowy"; }
|
||||
document.querySelector("#characters").textContent = `${editor.value.length} znaków`;
|
||||
const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0; document.querySelector("#words").textContent = `${words} słów`;
|
||||
}
|
||||
function applyUi({ write = false, replace = false } = {}) {
|
||||
editorWorkspace.className = `workspace view-${uiState.view}`;
|
||||
document.querySelectorAll("[data-view]").forEach(button => { const active = button.dataset.view === uiState.view; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); });
|
||||
const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.setAttribute("aria-pressed", String(markdown)); modeToggle.textContent = markdown ? "Markdown" : "Tekst"; modeToggle.title = markdown ? "Kliknij, aby pokazać tekst bez interpretacji" : "Kliknij, aby interpretować Markdown";
|
||||
render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel();
|
||||
}
|
||||
function applyRemote(content) { if (content === editor.value) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; 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,
|
||||
onStatus: state => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"),
|
||||
onAuthenticated: message => { if (passwordDialog.open) passwordDialog.close(); document.querySelector("#note-title").textContent = message.note_title; document.querySelector("#workspace-link").textContent = message.workspace_title; applyRemote(message.content); editor.focus(); },
|
||||
onDocument: message => { applyRemote(message.content); document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`; },
|
||||
onError: message => { document.querySelector("#password-error").textContent = message; 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("#note-title").textContent = info.title; document.querySelector("#workspace-link").textContent = info.workspace_title; document.querySelector("#workspace-link").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.querySelector("#back-workspace").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.title = `${info.title} · ${info.workspace_title}`; applyUi({ write: true, replace: true }); if (info.protected && !password) passwordDialog.showModal(); else connect(); } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Nie znaleziono notatki</h1><p>${e.message}</p><a href="/w/${encodeURIComponent(workspaceSlug)}">Wróć do workspace</a></div></main>`; }
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { uiState = { ...uiState, view: button.dataset.view }; applyUi({ write: true }); }));
|
||||
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
|
||||
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); });
|
||||
window.addEventListener("rustpad:urlchange", updateAddressLabel);
|
||||
document.querySelector("#copy-link").addEventListener("click", async () => { try { const url = currentShareUrl(uiState); await copyText(url); toast("Skopiowano link z widokiem"); } catch (e) { toast(e.message); } });
|
||||
document.querySelectorAll("[data-format]").forEach(button => button.addEventListener("click", () => applyFormat(editor, button.dataset.format)));
|
||||
editor.addEventListener("input", () => { render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Zapisywanie…"; saveTimer = setTimeout(() => socket?.update(editor.value), 250); });
|
||||
document.querySelector("#password-form").addEventListener("submit", event => { event.preventDefault(); password = document.querySelector("#open-password").value; setPassword(workspaceSlug, password); document.querySelector("#password-error").textContent = ""; connect(); });
|
||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Ładowanie…</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 => `<article class="revision"><time>${new Date(r.created_at.replace(" ", "T") + "Z").toLocaleString("pl-PL")}</time><button class="secondary-button" data-revision="${r.id}">Przywróć</button></article>`).join("") : '<p class="empty">Brak historii.</p>'; list.querySelectorAll("[data-revision]").forEach(button => button.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); toast("Przywrócono wersję"); })); } catch (e) { list.innerHTML = `<p class="error">${e.message}</p>`; } });
|
||||
document.querySelector("#close-history").addEventListener("click", () => { historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3];
|
||||
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter");
|
||||
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
|
||||
let password=getPassword(workspaceSlug), nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
|
||||
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
|
||||
function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
|
||||
function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;}
|
||||
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
function renderGutter(){const lines=editor.value.split("\n");owners=owners.slice(0,lines.length);while(owners.length<lines.length)owners.push(owners.at(-1)||nickname||"");gutter.innerHTML=lines.map((_,i)=>`<div title="${escapeHtml(owners[i]||"no author")}" style="--owner:${colorFor(owners[i])}">${i+1}</div>`).join("");document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);}
|
||||
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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}`;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();document.querySelector("#note-title").textContent=m.note_title;document.querySelector("#workspace-link").textContent=m.workspace_title;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("#note-title").textContent=info.title;document.querySelector("#workspace-link").textContent=info.workspace_title;document.querySelector("#workspace-link").href=`/w/${encodeURIComponent(workspaceSlug)}`;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();});
|
||||
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();});
|
||||
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;});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();});
|
||||
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>${new Date(r.created_at.replace(" ","T")+"Z").toLocaleString("en-US")}</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=>{const file=e.target.files[0];if(!file)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="";});
|
||||
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();
|
||||
|
||||
+32
-171
@@ -1,173 +1,34 @@
|
||||
import { api } from "./api.js?v=0.6.0";
|
||||
import { copyText } from "./clipboard.js?v=0.6.0";
|
||||
import { applyFormat } from "./editor-format.js?v=0.6.0";
|
||||
import { renderMarkdown } from "./markdown.js?v=0.6.0";
|
||||
import { PadSocket } from "./socket.js?v=0.6.0";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0";
|
||||
|
||||
const slug = location.pathname.split("/").filter(Boolean)[1];
|
||||
const passwordKey = `rustpad:pad:${slug}:password`;
|
||||
const editor = document.querySelector("#editor");
|
||||
const preview = document.querySelector("#preview");
|
||||
const editorWorkspace = document.querySelector("#editor-workspace");
|
||||
const modeToggle = document.querySelector("#mode-toggle");
|
||||
const passwordDialog = document.querySelector("#password-dialog");
|
||||
let password = sessionStorage.getItem(passwordKey) || "";
|
||||
let info;
|
||||
let socket;
|
||||
let saveTimer;
|
||||
let applyingRemote = false;
|
||||
let uiState = readEditorState();
|
||||
|
||||
function toast(text) {
|
||||
const element = document.querySelector("#toast");
|
||||
element.textContent = text;
|
||||
element.classList.add("visible");
|
||||
setTimeout(() => element.classList.remove("visible"), 1600);
|
||||
}
|
||||
|
||||
function setStatus(kind, text) {
|
||||
document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`;
|
||||
document.querySelector("#status-text").textContent = text;
|
||||
}
|
||||
|
||||
function updateAddressLabel() {
|
||||
document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (uiState.mode === "markdown") {
|
||||
preview.classList.remove("preview--raw");
|
||||
preview.innerHTML = renderMarkdown(editor.value);
|
||||
document.querySelector("#preview-label").textContent = "Podgląd Markdown";
|
||||
} else {
|
||||
preview.classList.add("preview--raw");
|
||||
preview.textContent = editor.value;
|
||||
document.querySelector("#preview-label").textContent = "Tekst źródłowy";
|
||||
}
|
||||
document.querySelector("#characters").textContent = `${editor.value.length} znaków`;
|
||||
const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0;
|
||||
document.querySelector("#words").textContent = `${words} słów`;
|
||||
}
|
||||
|
||||
function applyUi({ write = false, replace = false } = {}) {
|
||||
editorWorkspace.className = `workspace view-${uiState.view}`;
|
||||
document.querySelectorAll("[data-view]").forEach((button) => {
|
||||
const active = button.dataset.view === uiState.view;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const markdown = uiState.mode === "markdown";
|
||||
modeToggle.classList.toggle("active", markdown);
|
||||
modeToggle.setAttribute("aria-pressed", String(markdown));
|
||||
modeToggle.textContent = markdown ? "Markdown" : "Tekst";
|
||||
modeToggle.title = markdown ? "Pokaż tekst bez interpretacji" : "Interpretuj Markdown";
|
||||
render();
|
||||
if (write) writeEditorState(uiState, { replace });
|
||||
updateAddressLabel();
|
||||
}
|
||||
|
||||
function applyRemote(content) {
|
||||
if (content === editor.value) return;
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
applyingRemote = true;
|
||||
editor.value = content;
|
||||
editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length));
|
||||
applyingRemote = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
socket?.stop();
|
||||
socket = new PadSocket({
|
||||
slug,
|
||||
password,
|
||||
onStatus: (state) => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"),
|
||||
onAuthenticated: (message) => {
|
||||
if (passwordDialog.open) passwordDialog.close();
|
||||
document.querySelector("#pad-title").textContent = message.title;
|
||||
applyRemote(message.content);
|
||||
editor.focus();
|
||||
},
|
||||
onDocument: (message) => {
|
||||
applyRemote(message.content);
|
||||
document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
},
|
||||
onError: (message) => {
|
||||
document.querySelector("#password-error").textContent = message;
|
||||
if (info?.protected && !passwordDialog.open) passwordDialog.showModal();
|
||||
},
|
||||
});
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
info = await api(`/api/pads/${encodeURIComponent(slug)}`);
|
||||
document.querySelector("#pad-title").textContent = info.title;
|
||||
document.title = `${info.title} · RustPad`;
|
||||
applyUi({ write: true, replace: true });
|
||||
if (info.protected && !password) passwordDialog.showModal();
|
||||
else connect();
|
||||
} catch (error) {
|
||||
location.replace("/");
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => {
|
||||
uiState = { ...uiState, view: button.dataset.view };
|
||||
applyUi({ write: true });
|
||||
}));
|
||||
modeToggle.addEventListener("click", () => {
|
||||
uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" };
|
||||
applyUi({ write: true });
|
||||
});
|
||||
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); });
|
||||
window.addEventListener("rustpad:urlchange", updateAddressLabel);
|
||||
document.querySelector("#copy-link").addEventListener("click", async () => {
|
||||
try {
|
||||
await copyText(currentShareUrl(uiState));
|
||||
toast("Skopiowano link z widokiem");
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
});
|
||||
document.querySelectorAll("[data-format]").forEach((button) => button.addEventListener("click", () => applyFormat(editor, button.dataset.format)));
|
||||
editor.addEventListener("input", () => {
|
||||
render();
|
||||
if (applyingRemote) return;
|
||||
clearTimeout(saveTimer);
|
||||
document.querySelector("#save-state").textContent = "Zapisywanie…";
|
||||
saveTimer = setTimeout(() => socket?.update(editor.value), 250);
|
||||
});
|
||||
document.querySelector("#password-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
password = document.querySelector("#open-password").value;
|
||||
sessionStorage.setItem(passwordKey, password);
|
||||
document.querySelector("#password-error").textContent = "";
|
||||
connect();
|
||||
});
|
||||
const historyPanel = document.querySelector("#history-panel");
|
||||
document.querySelector("#history-button").addEventListener("click", async () => {
|
||||
historyPanel.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("history-open");
|
||||
const list = document.querySelector("#history-list");
|
||||
list.innerHTML = '<p class="empty">Ładowanie…</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((revision) => `<article class="revision"><time>${new Date(revision.created_at.replace(" ", "T") + "Z").toLocaleString("pl-PL")}</time><button class="secondary-button" data-revision="${revision.id}">Przywróć</button></article>`).join("") : '<p class="empty">Brak historii.</p>';
|
||||
list.querySelectorAll("[data-revision]").forEach((button) => button.addEventListener("click", async () => {
|
||||
await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) });
|
||||
toast("Przywrócono wersję");
|
||||
}));
|
||||
} catch (error) {
|
||||
list.innerHTML = `<p class="error">${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
document.querySelector("#close-history").addEventListener("click", () => {
|
||||
historyPanel.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("history-open");
|
||||
});
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
import { getNickname, setNickname } from "@rustpad/session";
|
||||
import { PadSocket } from "@rustpad/socket";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
const slug=location.pathname.split("/").filter(Boolean)[1];
|
||||
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter");
|
||||
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
|
||||
let password=sessionStorage.getItem(`rustpad:pad:${slug}:password`)||"", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
|
||||
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
|
||||
function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
|
||||
function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;}
|
||||
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
function renderGutter(){const lines=editor.value.split("\n");owners=owners.slice(0,lines.length);while(owners.length<lines.length)owners.push(owners.at(-1)||nickname||"");gutter.innerHTML=lines.map((_,i)=>`<div title="${escapeHtml(owners[i]||"no author")}" style="--owner:${colorFor(owners[i])}">${i+1}</div>`).join("");document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);}
|
||||
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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}`;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 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();document.querySelector("#pad-title").textContent=m.title;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.querySelector("#pad-title").textContent=info.title;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();});
|
||||
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();});
|
||||
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;});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>${new Date(r.created_at.replace(" ","T")+"Z").toLocaleString("en-US")}</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=>{const file=e.target.files[0];if(!file)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="";});
|
||||
initialize();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
|
||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||
const content = document.querySelector("#public-content");
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
async function renderMermaid(){const nodes=content.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
async function initialize(){try{const page=await api(`/api/public/${encodeURIComponent(token)}`);document.querySelector("#public-title").textContent=page.title;document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")}`;document.title=`${page.title} · RustPad`;content.innerHTML=renderMarkdown(page.content);await renderMermaid();}catch(error){content.innerHTML=`<p class="error">${String(error.message)}</p>`;}}
|
||||
document.querySelector("#copy-public-link").addEventListener("click",async()=>{try{await copyText(location.href);toast("Link copied");}catch(error){toast(error.message);}});
|
||||
initialize();
|
||||
@@ -1,6 +1,5 @@
|
||||
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
|
||||
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
|
||||
export function setPassword(workspaceSlug, password) {
|
||||
if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password);
|
||||
else sessionStorage.removeItem(passwordKey(workspaceSlug));
|
||||
}
|
||||
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
|
||||
export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; }
|
||||
export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); }
|
||||
|
||||
+7
-56
@@ -1,60 +1,11 @@
|
||||
export class NoteSocket {
|
||||
constructor({ workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }) {
|
||||
Object.assign(this, { workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError });
|
||||
this.socket = null; this.timer = null; this.closed = false;
|
||||
}
|
||||
connect() {
|
||||
clearTimeout(this.timer); this.onStatus?.("connecting");
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
this.socket = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`);
|
||||
this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null })));
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); }
|
||||
if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); }
|
||||
if (message.type === "document") this.onDocument?.(message);
|
||||
});
|
||||
this.socket.addEventListener("close", () => { if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } });
|
||||
this.socket.addEventListener("error", () => this.socket.close());
|
||||
}
|
||||
update(content) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); }
|
||||
stop() { this.closed = true; clearTimeout(this.timer); this.socket?.close(); }
|
||||
constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
|
||||
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
|
||||
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
|
||||
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||
}
|
||||
|
||||
export class PadSocket {
|
||||
constructor({ slug, password, onStatus, onAuthenticated, onDocument, onError }) {
|
||||
Object.assign(this, { slug, password, onStatus, onAuthenticated, onDocument, onError });
|
||||
this.socket = null;
|
||||
this.timer = null;
|
||||
this.closed = false;
|
||||
}
|
||||
connect() {
|
||||
clearTimeout(this.timer);
|
||||
this.closed = false;
|
||||
this.onStatus?.("connecting");
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
this.socket = new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);
|
||||
this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null })));
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); }
|
||||
if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); }
|
||||
if (message.type === "document") this.onDocument?.(message);
|
||||
});
|
||||
this.socket.addEventListener("close", () => {
|
||||
if (!this.closed) {
|
||||
this.onStatus?.("offline");
|
||||
this.timer = setTimeout(() => this.connect(), 1500);
|
||||
}
|
||||
});
|
||||
this.socket.addEventListener("error", () => this.socket.close());
|
||||
}
|
||||
update(content) {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content }));
|
||||
}
|
||||
stop() {
|
||||
this.closed = true;
|
||||
clearTimeout(this.timer);
|
||||
this.socket?.close();
|
||||
}
|
||||
constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
|
||||
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
|
||||
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { api } from "./api.js?v=0.6.0"; import { copyText } from "./clipboard.js?v=0.6.0"; import { getPassword, setPassword } from "./session.js?v=0.6.0";
|
||||
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>Aktualizacja: ${new Date(note.updated_at).toLocaleString("pl-PL")}</p></a>`).join("") : '<p class="empty">Brak notatek.</p>'; }
|
||||
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: ${new Date(note.updated_at).toLocaleString("en-US")}</p></a>`).join("") : '<p class="empty">No notes yet.</p>'; }
|
||||
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
||||
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.includes("hasło")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } }
|
||||
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("Skopiowano link"); } catch (e) { toast(e.message); } }); init();
|
||||
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();
|
||||
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
<!doctype html><html lang="pl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Notatka · RustPad</title><link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__"><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="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">Ładowanie…</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Łączenie…</span></div><button id="copy-link" class="secondary-button">Kopiuj link</button><button id="history-button" class="secondary-button">Historia</button></div></header>
|
||||
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold"><strong>B</strong></button><button data-format="italic"><em>I</em></button><button data-format="strike"><s>S</s></button><button data-format="heading">H2</button><button data-format="bullet">• Lista</button><button data-format="number">1. Lista</button><button data-format="quote">Cytat</button><button data-format="link">Link</button></div><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">Edytuj</button><button data-view="split" class="active">Podział</button><button data-view="preview">Podgląd</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Edytor</div><textarea id="editor" placeholder="Zacznij pisać…"></textarea></div><div class="preview-column"><div id="preview-label" class="column-label">Podgląd Markdown</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div><span id="characters">0 znaków</span> · <span id="words">0 słów</span></div><span id="save-state">Zmiany zapisują się automatycznie</span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><h2>Historia</h2><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
|
||||
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Workspace chroniony</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Hasło"><p id="password-error" class="form-message error"></p><button class="primary-button">Otwórz</button><a id="back-workspace" class="dialog-link" href="/">Wróć</a></form></dialog><div id="toast" class="toast"></div></body></html>
|
||||
<!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 · 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="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">Loading…</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"><strong>B</strong></button><button data-format="italic"><em>I</em></button><button data-format="strike"><s>S</s></button><button data-format="heading">H2</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><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><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><textarea id="editor" 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>
|
||||
<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>
|
||||
|
||||
+5
-19
@@ -1,19 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Notatka · RustPad</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__">
|
||||
<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">Ładowanie…</h1><p id="pad-url" class="document-url"></p></div></div>
|
||||
<div class="header-actions"><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Łączenie…</span></div><button id="copy-link" class="secondary-button">Kopiuj link</button><button id="history-button" class="secondary-button">Historia</button></div>
|
||||
</header>
|
||||
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold"><strong>B</strong></button><button data-format="italic"><em>I</em></button><button data-format="strike"><s>S</s></button><button data-format="heading">H2</button><button data-format="bullet">• Lista</button><button data-format="number">1. Lista</button><button data-format="quote">Cytat</button><button data-format="link">Link</button></div><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">Edytuj</button><button data-view="split" class="active">Podział</button><button data-view="preview">Podgląd</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Edytor</div><textarea id="editor" placeholder="Zacznij pisać…"></textarea></div><div class="preview-column"><div id="preview-label" class="column-label">Podgląd Markdown</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div><span id="characters">0 znaków</span> · <span id="words">0 słów</span></div><span id="save-state">Zmiany zapisują się automatycznie</span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><h2>Historia</h2><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
|
||||
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Notatka chroniona</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Hasło"><p id="password-error" class="form-message error"></p><button class="primary-button">Otwórz</button><a class="dialog-link" href="/">Wróć</a></form></dialog><div id="toast" class="toast"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!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 · 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">Loading…</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"><strong>B</strong></button><button data-format="italic"><em>I</em></button><button data-format="strike"><s>S</s></button><button data-format="heading">H2</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><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><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><textarea id="editor" 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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!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>Published note · 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/public.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body class="public-page">
|
||||
<header class="public-header">
|
||||
<a class="brand" href="/">RustPad</a>
|
||||
<button id="copy-public-link" class="secondary-button">Copy link</button>
|
||||
</header>
|
||||
<main class="public-document">
|
||||
<h1 id="public-title">Loading…</h1>
|
||||
<p id="public-meta" class="public-meta"></p>
|
||||
<article id="public-content" class="markdown-body public-content"></article>
|
||||
</main>
|
||||
<div id="toast" class="toast"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -205,3 +205,37 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||
.home-layout--wide { width: min(100% - 24px, 560px); }
|
||||
.create-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Collaborative editor additions */
|
||||
.editor-shell { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 0; overflow: hidden; background: #0d1015; }
|
||||
.line-gutter { width: 62px; overflow: hidden; padding: 24px 8px 24px 0; border-right: 1px solid var(--border); color: var(--muted-2); font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; user-select: none; }
|
||||
.line-gutter div { height: 1.72em; padding-right: 8px; border-right: 3px solid var(--owner, transparent); }
|
||||
.hide-line-numbers .editor-shell { grid-template-columns: 0 minmax(0, 1fr); }
|
||||
.hide-line-numbers .line-gutter { width: 0; padding: 0; border: 0; }
|
||||
.editor-shell textarea { padding-left: 18px; }
|
||||
.line-toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: .78rem; white-space: nowrap; }
|
||||
.line-toggle input { width: auto; min-height: auto; margin: 0; accent-color: var(--accent); }
|
||||
.user-chip { display: inline-flex; align-items: center; gap: 7px; color: #dce2eb; font-size: .78rem; }
|
||||
.user-chip::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--owner, var(--accent)); }
|
||||
.dialog-copy { margin: 0 0 4px; color: var(--muted); line-height: 1.5; }
|
||||
.history-header h2 { margin: 0; }
|
||||
.history-header p { margin: 4px 0 0; color: var(--muted-2); font-size: .75rem; }
|
||||
.revision__marker { border-color: var(--owner, #8b7af4); background: var(--owner, #8b7af4); }
|
||||
.revision__meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.revision__meta strong { font-size: .82rem; }
|
||||
.revision__snippet { margin: 8px 0 0; color: var(--muted); font-size: .76rem; line-height: 1.45; }
|
||||
.revision__preview { max-height: 180px; overflow: auto; margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: #0b0e13; color: #c9d0da; font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; }
|
||||
.revision button + button { margin-left: 12px; }
|
||||
.mermaid { overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; }
|
||||
@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-chip { display: none; } }
|
||||
|
||||
.markdown-body img { display: block; max-width: 100%; height: auto; margin: 16px auto; border-radius: 10px; }
|
||||
.markdown-body a { overflow-wrap: anywhere; }
|
||||
.public-page { min-height: 100vh; background: var(--background); }
|
||||
.public-header { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; min-height: 64px; padding: 0 max(20px, calc((100vw - 900px) / 2)); border-bottom: 1px solid var(--border); background: rgba(13,16,21,.92); backdrop-filter: blur(12px); }
|
||||
.public-document { width: min(900px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 96px; }
|
||||
.public-document > h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1.08; }
|
||||
.public-meta { margin: 12px 0 36px; color: var(--muted); font-size: .8rem; }
|
||||
.public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
||||
.public-content img { cursor: zoom-in; }
|
||||
@media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html><html lang="pl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Workspace · RustPad</title><link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__"><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">Ładowanie…</h1><p id="workspace-url" class="document-url"></p></div></div><div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Kopiuj link</button></div></header>
|
||||
<main class="workspace-page"><section class="workspace-top"><div><h2>Notatki</h2><p>Wybierz notatkę albo utwórz nową.</p></div><button id="new-note-button" class="primary-button inline-button">Nowa notatka</button></section><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>Workspace chroniony</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Hasło"><p id="password-error" class="form-message error"></p><button class="primary-button">Otwórz</button><a href="/" class="dialog-link">Anuluj</a></form></dialog>
|
||||
<dialog id="note-dialog"><form id="note-form" class="dialog-panel"><h2>Nowa notatka</h2><input id="note-name" maxlength="80" required placeholder="Nazwa notatki"><p id="note-error" class="form-message error"></p><div class="dialog-actions"><button type="button" id="cancel-note" class="secondary-button">Anuluj</button><button class="primary-button">Utwórz</button></div></form></dialog><div id="toast" class="toast"></div></body></html>
|
||||
<!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 · 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">Loading…</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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user