first commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
target
|
||||||
|
rustpad.db
|
||||||
|
rustpad.db-shm
|
||||||
|
rustpad.db-wal
|
||||||
|
.env
|
||||||
|
.env.docker
|
||||||
|
*.log
|
||||||
|
README.md
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Serwer deweloperski
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# SQLite
|
||||||
|
DATABASE_URL=sqlite://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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
rustpad.db
|
||||||
|
rustpad.db-shm
|
||||||
|
rustpad.db-wal
|
||||||
|
.env
|
||||||
|
.env.docker
|
||||||
|
*.log
|
||||||
Generated
+2525
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustpad"
|
||||||
|
version = "0.6.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argon2 = "0.5"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
futures-util = "0.3"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
slug = "0.1"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY src ./src
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --system --uid 10001 --create-home rustpad \
|
||||||
|
&& mkdir -p /app/static /data \
|
||||||
|
&& chown -R rustpad:rustpad /app /data
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad
|
||||||
|
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_MAX_CONNECTIONS=8 \
|
||||||
|
STATIC_DIR=/app/static \
|
||||||
|
RUST_LOG=rustpad=info,tower_http=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl --fail --silent http://127.0.0.1:3000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["rustpad"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# RustPad 0.6.0
|
||||||
|
|
||||||
|
Współdzielony edytor Markdown z dwoma niezależnymi trybami:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Otwórz `http://127.0.0.1:3000`.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.docker.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Baza znajduje się w wolumenie `rustpad_data`.
|
||||||
|
|
||||||
|
## Adresy
|
||||||
|
|
||||||
|
```text
|
||||||
|
/p/notatka?view=split&mode=markdown
|
||||||
|
/w/workspace
|
||||||
|
/w/workspace/n/notatka?view=split&mode=markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- `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`.
|
||||||
|
|
||||||
|
## Dane istniejące
|
||||||
|
|
||||||
|
Tabele `pads` i `revisions` pozostają aktywne. Workspace są dodatkiem, nie zamiennikiem zwykłych notatek. Przed aktualizacją wykonaj kopię `rustpad.db`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
rustpad:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rustpad:${IMAGE_TAG:-local}
|
||||||
|
restart: unless-stopped
|
||||||
|
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_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
||||||
|
STATIC_DIR: ${STATIC_DIR:-/app/static}
|
||||||
|
ASSET_VERSION: ${ASSET_VERSION:-0.0.1}
|
||||||
|
RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info}
|
||||||
|
ports:
|
||||||
|
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
|
||||||
|
volumes:
|
||||||
|
- rustpad_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rustpad_data:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pad_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisions_pad_id ON revisions(pad_id, id DESC);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id INTEGER NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(workspace_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS note_revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
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ą.
|
||||||
|
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;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO notes (id, workspace_id, slug, title, content, created_at, updated_at)
|
||||||
|
SELECT id, id, 'notatka', title, content, created_at, updated_at FROM pads;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO note_revisions (id, note_id, content, created_at)
|
||||||
|
SELECT id, pad_id, content, created_at FROM revisions;
|
||||||
+500
@@ -0,0 +1,500 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use slug::slugify;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db,
|
||||||
|
state::{NoteUpdate, SharedState},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH: usize = 80;
|
||||||
|
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||||
|
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||||
|
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateWorkspaceRequest {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateWorkspaceResponse {
|
||||||
|
slug: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PasswordRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateNoteRequest {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RestoreRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
revision_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WorkspaceInfo {
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
protected: bool,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WorkspaceOpenResponse {
|
||||||
|
workspace: WorkspaceInfo,
|
||||||
|
notes: Vec<NoteListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NoteListItem {
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NoteInfo {
|
||||||
|
workspace_slug: String,
|
||||||
|
workspace_title: String,
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
protected: bool,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 password = validate_password(payload.password.as_deref())?;
|
||||||
|
let slug = unique_workspace_slug(&state, title).await?;
|
||||||
|
|
||||||
|
db::create_workspace(&state.db, &slug, title, password).await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(CreateWorkspaceResponse {
|
||||||
|
url: format!("/w/{slug}"),
|
||||||
|
slug,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn workspace_info(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(workspace_slug): Path<String>,
|
||||||
|
) -> Result<Json<WorkspaceInfo>, ApiError> {
|
||||||
|
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
|
Ok(Json(workspace_info_from(&workspace)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_workspace(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(workspace_slug): Path<String>,
|
||||||
|
Json(payload): Json<PasswordRequest>,
|
||||||
|
) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
|
||||||
|
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||||
|
let notes = db::list_notes(&state.db, workspace.id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|note| NoteListItem {
|
||||||
|
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
||||||
|
slug: note.slug,
|
||||||
|
title: note.title,
|
||||||
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(WorkspaceOpenResponse {
|
||||||
|
workspace: workspace_info_from(&workspace),
|
||||||
|
notes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_note(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(workspace_slug): Path<String>,
|
||||||
|
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 base = slugify(title);
|
||||||
|
if base.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||||
|
let note = db::create_note(&state.db, workspace.id, &slug, title).await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(NoteListItem {
|
||||||
|
url: format!("/w/{workspace_slug}/n/{slug}"),
|
||||||
|
slug: note.slug,
|
||||||
|
title: note.title,
|
||||||
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn note_info(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<NoteInfo>, ApiError> {
|
||||||
|
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
|
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
|
||||||
|
Ok(Json(NoteInfo {
|
||||||
|
workspace_slug: workspace.slug,
|
||||||
|
workspace_title: workspace.title,
|
||||||
|
slug: note.slug,
|
||||||
|
title: note.title,
|
||||||
|
protected: workspace.password_hash.is_some(),
|
||||||
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn history(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
Json(payload): Json<PasswordRequest>,
|
||||||
|
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||||
|
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||||
|
let _ = workspace;
|
||||||
|
Ok(Json(db::list_revisions(&state.db, note.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
Json(payload): Json<RestoreRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||||
|
let content: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT content FROM note_revisions WHERE id = ? AND note_id = ?",
|
||||||
|
)
|
||||||
|
.bind(payload.revision_id)
|
||||||
|
.bind(note.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.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?;
|
||||||
|
let update = NoteUpdate {
|
||||||
|
content,
|
||||||
|
revision_id,
|
||||||
|
updated_at,
|
||||||
|
};
|
||||||
|
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorized_workspace(
|
||||||
|
state: &SharedState,
|
||||||
|
slug: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<db::Workspace, ApiError> {
|
||||||
|
let workspace = db::find_workspace(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
|
if !db::verify_workspace_password(&workspace, password) {
|
||||||
|
return Err(ApiError::unauthorized());
|
||||||
|
}
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized_note(
|
||||||
|
state: &SharedState,
|
||||||
|
workspace_slug: &str,
|
||||||
|
note_slug: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||||
|
let workspace = authorized_workspace(state, workspace_slug, password).await?;
|
||||||
|
let note = db::find_note(&state.db, workspace.id, note_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
Ok((workspace, note))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo {
|
||||||
|
WorkspaceInfo {
|
||||||
|
slug: workspace.slug.clone(),
|
||||||
|
title: workspace.title.clone(),
|
||||||
|
protected: workspace.password_hash.is_some(),
|
||||||
|
created_at: db::normalize_timestamp(&workspace.created_at),
|
||||||
|
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||||
|
let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
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",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|
||||||
|
|| db::find_workspace(&state.db, &base).await?.is_some();
|
||||||
|
if !needs_suffix {
|
||||||
|
return Ok(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
let candidate = format!("{base}-{}", db::random_suffix(8));
|
||||||
|
if db::find_workspace(&state.db, &candidate).await?.is_none() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unique_note_slug(
|
||||||
|
state: &SharedState,
|
||||||
|
workspace_id: i64,
|
||||||
|
base: &str,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
if db::find_note(&state.db, workspace_id, base).await?.is_none() {
|
||||||
|
return Ok(base.to_owned());
|
||||||
|
}
|
||||||
|
for _ in 0..8 {
|
||||||
|
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||||
|
if db::find_note(&state.db, workspace_id, &candidate)
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreatePadRequest {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreatePadResponse {
|
||||||
|
slug: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PadInfo {
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
protected: bool,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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"));
|
||||||
|
}
|
||||||
|
let slug = unique_pad_slug(&state, &base).await?;
|
||||||
|
db::create_pad(&state.db, &slug, title, password).await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(CreatePadResponse {
|
||||||
|
url: format!("/p/{slug}"),
|
||||||
|
slug,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pad_info(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<PadInfo>, ApiError> {
|
||||||
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
Ok(Json(PadInfo {
|
||||||
|
slug: pad.slug,
|
||||||
|
title: pad.title,
|
||||||
|
protected: pad.password_hash.is_some(),
|
||||||
|
created_at: db::normalize_timestamp(&pad.created_at),
|
||||||
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pad_history(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<PasswordRequest>,
|
||||||
|
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||||
|
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||||
|
Ok(Json(db::list_pad_revisions(&state.db, pad.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pad_restore(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<RestoreRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||||
|
let content: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT content FROM revisions WHERE id = ? AND pad_id = ?",
|
||||||
|
)
|
||||||
|
.bind(payload.revision_id)
|
||||||
|
.bind(pad.id)
|
||||||
|
.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 update = NoteUpdate {
|
||||||
|
content,
|
||||||
|
revision_id,
|
||||||
|
updated_at,
|
||||||
|
};
|
||||||
|
let _ = state.pad_channel(&slug).await.send(update);
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized_pad(
|
||||||
|
state: &SharedState,
|
||||||
|
slug: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<db::Pad, ApiError> {
|
||||||
|
let pad = db::find_pad(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
if !db::verify_pad_password(&pad, password) {
|
||||||
|
return Err(ApiError::unauthorized());
|
||||||
|
}
|
||||||
|
Ok(pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiError> {
|
||||||
|
if db::find_pad(&state.db, base).await?.is_none() {
|
||||||
|
return Ok(base.to_owned());
|
||||||
|
}
|
||||||
|
for _ in 0..8 {
|
||||||
|
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||||
|
if db::find_pad(&state.db, &candidate).await?.is_none() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiError {
|
||||||
|
status: StatusCode,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
fn bad_request(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn unauthorized() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNAUTHORIZED,
|
||||||
|
message: "Nieprawidłowe hasło".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn not_found_workspace() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Nie znaleziono workspace".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn not_found_note() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Nie znaleziono notatki".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn not_found_revision() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Nie znaleziono wersji".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn internal(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for ApiError {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
tracing::error!(%error, "database error");
|
||||||
|
Self::internal("Błąd bazy danych")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.status, Json(serde_json::json!({"error": self.message}))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, HeaderValue, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||||
|
|
||||||
|
use crate::{api, db, state::SharedState, websocket};
|
||||||
|
|
||||||
|
pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(home))
|
||||||
|
.route("/p/{slug}", get(pad))
|
||||||
|
.route("/w/{workspace_slug}", get(workspace))
|
||||||
|
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
||||||
|
.route("/health", get(health))
|
||||||
|
.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}/restore", post(api::pad_restore))
|
||||||
|
.route("/api/workspaces", post(api::create_workspace))
|
||||||
|
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||||
|
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
|
||||||
|
.route("/api/workspaces/{workspace_slug}/notes", post(api::create_note))
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||||
|
get(api::note_info),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
|
||||||
|
post(api::history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
|
||||||
|
post(api::restore),
|
||||||
|
)
|
||||||
|
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||||
|
.route(
|
||||||
|
"/ws/{workspace_slug}/{note_slug}",
|
||||||
|
get(websocket::upgrade),
|
||||||
|
)
|
||||||
|
.nest_service("/assets", ServeDir::new(static_dir))
|
||||||
|
.fallback(not_found)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn home(State(state): State<SharedState>) -> Response {
|
||||||
|
versioned_html(include_str!("../static/home.html"), &state.asset_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pad(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match db::find_pad(&state.db, &slug).await {
|
||||||
|
Ok(Some(_)) => versioned_html(include_str!("../static/pad.html"), &state.asset_version),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"404",
|
||||||
|
"Nie znaleziono notatki",
|
||||||
|
"Ta notatka nie istnieje albo została usunięta.",
|
||||||
|
"/",
|
||||||
|
"Strona główna",
|
||||||
|
&state.asset_version,
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, %slug, "failed to load standalone pad");
|
||||||
|
internal_error(&state.asset_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn workspace(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(workspace_slug): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match db::find_workspace(&state.db, &workspace_slug).await {
|
||||||
|
Ok(Some(_)) => versioned_html(
|
||||||
|
include_str!("../static/workspace.html"),
|
||||||
|
&state.asset_version,
|
||||||
|
),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"404",
|
||||||
|
"Nie znaleziono workspace",
|
||||||
|
"Ten workspace nie istnieje albo został usunięty.",
|
||||||
|
"/",
|
||||||
|
"Strona główna",
|
||||||
|
&state.asset_version,
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, %workspace_slug, "failed to load workspace page");
|
||||||
|
internal_error(&state.asset_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn note(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
|
||||||
|
Ok(Some(workspace)) => workspace,
|
||||||
|
Ok(None) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"404",
|
||||||
|
"Nie znaleziono workspace",
|
||||||
|
"Workspace tej notatki nie istnieje albo został usunięty.",
|
||||||
|
"/",
|
||||||
|
"Strona główna",
|
||||||
|
&state.asset_version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, %workspace_slug, "failed to load note workspace");
|
||||||
|
return internal_error(&state.asset_version);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||||
|
Ok(Some(_)) => versioned_html(include_str!("../static/note.html"), &state.asset_version),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"404",
|
||||||
|
"Nie znaleziono notatki",
|
||||||
|
"Ta notatka nie istnieje albo została usunięta.",
|
||||||
|
&format!("/w/{workspace_slug}"),
|
||||||
|
"Wróć do workspace",
|
||||||
|
&state.asset_version,
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page");
|
||||||
|
internal_error(&state.asset_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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ą.",
|
||||||
|
"/",
|
||||||
|
"Strona główna",
|
||||||
|
&state.asset_version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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ę.",
|
||||||
|
"/",
|
||||||
|
"Strona główna",
|
||||||
|
asset_version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(
|
||||||
|
status: StatusCode,
|
||||||
|
code: &str,
|
||||||
|
title: &str,
|
||||||
|
message: &str,
|
||||||
|
primary_url: &str,
|
||||||
|
primary_label: &str,
|
||||||
|
asset_version: &str,
|
||||||
|
) -> Response {
|
||||||
|
let html = include_str!("../static/error.html")
|
||||||
|
.replace("__ASSET_VERSION__", &escape_html(asset_version))
|
||||||
|
.replace("__ERROR_CODE__", &escape_html(code))
|
||||||
|
.replace("__ERROR_TITLE__", &escape_html(title))
|
||||||
|
.replace("__ERROR_MESSAGE__", &escape_html(message))
|
||||||
|
.replace("__PRIMARY_URL__", &escape_html(primary_url))
|
||||||
|
.replace("__PRIMARY_LABEL__", &escape_html(primary_label));
|
||||||
|
|
||||||
|
let mut response = (status, Html(html)).into_response();
|
||||||
|
no_store(&mut response);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn versioned_html(template: &str, asset_version: &str) -> Response {
|
||||||
|
let html = template.replace("__ASSET_VERSION__", asset_version);
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
no_store(&mut response);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_store(response: &mut Response) {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use std::{env, net::IpAddr};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub host: IpAddr,
|
||||||
|
pub port: u16,
|
||||||
|
pub database_url: String,
|
||||||
|
pub database_max_connections: u32,
|
||||||
|
pub static_dir: String,
|
||||||
|
pub asset_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
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()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"),
|
||||||
|
database_max_connections,
|
||||||
|
static_dir: env_var("STATIC_DIR", "static"),
|
||||||
|
asset_version: env::var("ASSET_VERSION")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_var(name: &str, default: &str) -> String {
|
||||||
|
env::var(name).unwrap_or_else(|_| default.to_owned())
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{FromRow, SqlitePool};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: i64,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub workspace_id: i64,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, FromRow)]
|
||||||
|
pub struct Revision {
|
||||||
|
pub id: i64,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Workspace>(
|
||||||
|
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_workspace(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
slug: &str,
|
||||||
|
title: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
|
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(title)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query_as::<_, Workspace>(
|
||||||
|
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(result.last_insert_rowid())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
||||||
|
match (&workspace.password_hash, password.filter(|value| !value.is_empty())) {
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| {
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.is_some(),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_note(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
workspace_id: i64,
|
||||||
|
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 = ?",
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_note(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
workspace_id: i64,
|
||||||
|
slug: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<Note, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(title)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query_as::<_, Note>(
|
||||||
|
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(result.last_insert_rowid())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_revision(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
note_id: i64,
|
||||||
|
workspace_id: i64,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
.bind(content)
|
||||||
|
.bind(note_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
.bind(workspace_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)")
|
||||||
|
.bind(note_id)
|
||||||
|
.bind(content)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
||||||
|
.bind(note_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok((result.last_insert_rowid(), updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
.bind(note_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_suffix(length: usize) -> String {
|
||||||
|
const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
let mut bytes = vec![0_u8; length];
|
||||||
|
let mut rng = OsRng;
|
||||||
|
rng.fill_bytes(&mut bytes);
|
||||||
|
bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> String {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.expect("Argon2 hashing should succeed")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_timestamp(value: &str) -> String {
|
||||||
|
DateTime::parse_from_rfc3339(value)
|
||||||
|
.map(|dt| dt.with_timezone(&Utc).to_rfc3339())
|
||||||
|
.unwrap_or_else(|_| value.replace(' ', "T") + "Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct Pad {
|
||||||
|
pub id: i64,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: 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 = ?",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pad(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
slug: &str,
|
||||||
|
title: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<Pad, sqlx::Error> {
|
||||||
|
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(title)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query_as::<_, Pad>(
|
||||||
|
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(result.last_insert_rowid())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
||||||
|
match (&pad.password_hash, password.filter(|value| !value.is_empty())) {
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| {
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.is_some(),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_pad_revision(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
pad_id: i64,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
.bind(content)
|
||||||
|
.bind(pad_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)")
|
||||||
|
.bind(pad_id)
|
||||||
|
.bind(content)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
||||||
|
.bind(pad_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok((result.last_insert_rowid(), updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_pad_revisions(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
.bind(pad_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
mod api;
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod state;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use state::AppState;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
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);
|
||||||
|
let address = SocketAddr::new(config.host, config.port);
|
||||||
|
let listener = TcpListener::bind(address).await?;
|
||||||
|
|
||||||
|
info!(%address, asset_version = %config.asset_version, "RustPad is running");
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "rustpad=debug,tower_http=info".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
|
||||||
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NoteUpdate {
|
||||||
|
pub content: String,
|
||||||
|
pub revision_id: i64,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: SqlitePool,
|
||||||
|
pub asset_version: String,
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedState = Arc<AppState>;
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ClientMessage {
|
||||||
|
Authenticate { password: Option<String> },
|
||||||
|
Update { content: 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,
|
||||||
|
},
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum PadServerMessage {
|
||||||
|
Authenticated { title: String, content: String },
|
||||||
|
Document { content: String, revision_id: i64, updated_at: 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!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">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>__ERROR_TITLE__ · RustPad</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="error-page">
|
||||||
|
<section class="error-card" aria-labelledby="error-title">
|
||||||
|
<p class="error-code">__ERROR_CODE__</p>
|
||||||
|
<h1 id="error-title">__ERROR_TITLE__</h1>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!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>RustPad</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css?v=__ASSET_VERSION__">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<p id="pad-error" class="form-message error" role="alert"></p>
|
||||||
|
<button id="pad-button" class="primary-button" type="submit">Utwórz notatkę</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel create-panel">
|
||||||
|
<div class="create-panel__heading">
|
||||||
|
<h2>Workspace</h2>
|
||||||
|
<p>Przestrzeń z listą wielu notatek.</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>
|
||||||
|
</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>
|
||||||
|
<p id="workspace-error" class="form-message error" role="alert"></p>
|
||||||
|
<button id="workspace-button" class="primary-button" type="submit">Utwórz workspace</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || `Błąd ${response.status}`);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") throw new Error("Przekroczono czas odpowiedzi serwera");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export async function copyText(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = text;
|
||||||
|
input.setAttribute("readonly", "");
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.opacity = "0";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
input.remove();
|
||||||
|
if (!copied) throw new Error("Nie udało się skopiować linku");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export function applyFormat(editor, format) {
|
||||||
|
const wrap = (before, after = before, placeholder = "tekst") => {
|
||||||
|
const start = editor.selectionStart, end = editor.selectionEnd;
|
||||||
|
const selected = editor.value.slice(start, end) || placeholder;
|
||||||
|
editor.setRangeText(before + selected + after, start, end, "select");
|
||||||
|
};
|
||||||
|
const prefix = (value) => {
|
||||||
|
const start = editor.selectionStart, end = editor.selectionEnd;
|
||||||
|
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
const selected = editor.value.slice(lineStart, end);
|
||||||
|
editor.setRangeText(selected.split("\n").map((line, index) => typeof value === "function" ? value(index) + line : value + line).join("\n"), lineStart, end, "select");
|
||||||
|
};
|
||||||
|
if (format === "bold") wrap("**");
|
||||||
|
if (format === "italic") wrap("*");
|
||||||
|
if (format === "strike") wrap("~~");
|
||||||
|
if (format === "heading") prefix("## ");
|
||||||
|
if (format === "bullet") prefix("- ");
|
||||||
|
if (format === "number") prefix((index) => `${index + 1}. `);
|
||||||
|
if (format === "quote") prefix("> ");
|
||||||
|
if (format === "link") wrap("[", "](https://)", "opis linku");
|
||||||
|
editor.focus();
|
||||||
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { api } from "./api.js?v=0.6.0";
|
||||||
|
|
||||||
|
function slugify(value, fallback) {
|
||||||
|
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPreview(inputId, previewId, countId, prefix, fallback) {
|
||||||
|
const input = document.querySelector(inputId);
|
||||||
|
const preview = document.querySelector(previewId);
|
||||||
|
const count = document.querySelector(countId);
|
||||||
|
const update = () => {
|
||||||
|
count.textContent = `${input.value.length}/80`;
|
||||||
|
preview.textContent = `${prefix}${slugify(input.value, fallback)}`;
|
||||||
|
};
|
||||||
|
input.addEventListener("input", update);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(button, busy, idleText, busyText) {
|
||||||
|
button.disabled = busy;
|
||||||
|
button.textContent = busy ? busyText : idleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "notatka");
|
||||||
|
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
|
||||||
|
|
||||||
|
document.querySelectorAll(".password-toggle").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const input = document.getElementById(button.dataset.target);
|
||||||
|
const show = input.type === "password";
|
||||||
|
input.type = show ? "text" : "password";
|
||||||
|
button.textContent = show ? "Ukryj" : "Pokaż";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#pad-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = document.querySelector("#pad-name");
|
||||||
|
const password = document.querySelector("#pad-password");
|
||||||
|
const button = document.querySelector("#pad-button");
|
||||||
|
const error = document.querySelector("#pad-error");
|
||||||
|
error.textContent = "";
|
||||||
|
setBusy(button, true, "Utwórz notatkę", "Tworzenie…");
|
||||||
|
try {
|
||||||
|
const payload = { name: name.value.trim() };
|
||||||
|
if (password.value) payload.password = password.value;
|
||||||
|
const result = await api("/api/pads", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
if (password.value) sessionStorage.setItem(`rustpad:pad:${result.slug}:password`, password.value);
|
||||||
|
window.location.assign(`${result.url}?view=split&mode=markdown`);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.textContent = requestError.message;
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false, "Utwórz notatkę", "Tworzenie…");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#workspace-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = document.querySelector("#workspace-name");
|
||||||
|
const password = document.querySelector("#workspace-password");
|
||||||
|
const button = document.querySelector("#workspace-button");
|
||||||
|
const error = document.querySelector("#workspace-error");
|
||||||
|
error.textContent = "";
|
||||||
|
setBusy(button, true, "Utwórz workspace", "Tworzenie…");
|
||||||
|
try {
|
||||||
|
const payload = { name: name.value.trim() };
|
||||||
|
if (password.value) payload.password = password.value;
|
||||||
|
const result = await api("/api/workspaces", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
if (password.value) sessionStorage.setItem(`rustpad:workspace:${result.slug}:password`, password.value);
|
||||||
|
window.location.assign(result.url);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.textContent = requestError.message;
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false, "Utwórz workspace", "Tworzenie…");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
function escapeHtml(value) {
|
||||||
|
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||||
|
}
|
||||||
|
function inline(value) {
|
||||||
|
return escapeHtml(value)
|
||||||
|
.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>');
|
||||||
|
}
|
||||||
|
export function renderMarkdown(source) {
|
||||||
|
let html = "", inCode = false, 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+(.+)$/);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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"); });
|
||||||
|
initialize();
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
initialize();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const VIEWS = new Set(["edit", "split", "preview"]);
|
||||||
|
const MODES = new Set(["markdown", "text"]);
|
||||||
|
|
||||||
|
export function readEditorState() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
|
||||||
|
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeEditorState(state, { replace = false } = {}) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("view", state.view);
|
||||||
|
url.searchParams.set("mode", state.mode);
|
||||||
|
const method = replace ? "replaceState" : "pushState";
|
||||||
|
window.history[method]({ ...state }, "", `${url.pathname}${url.search}${url.hash}`);
|
||||||
|
window.dispatchEvent(new CustomEvent("rustpad:urlchange", { detail: { url: url.href } }));
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentShareUrl(state) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("view", state.view);
|
||||||
|
url.searchParams.set("mode", state.mode);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
@@ -0,0 +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";
|
||||||
|
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 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 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();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
background: #0b0d11;
|
||||||
|
color: #f4f6f8;
|
||||||
|
--bg: #0b0d11;
|
||||||
|
--surface: #11141a;
|
||||||
|
--surface-2: #151922;
|
||||||
|
--surface-3: #1a1f29;
|
||||||
|
--border: #272d38;
|
||||||
|
--border-strong: #343c49;
|
||||||
|
--text: #f4f6f8;
|
||||||
|
--muted: #929cab;
|
||||||
|
--muted-2: #697383;
|
||||||
|
--accent: #7c68ee;
|
||||||
|
--accent-hover: #8d79f8;
|
||||||
|
--accent-soft: #272246;
|
||||||
|
--danger: #ff7b91;
|
||||||
|
--success: #40d3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; background: var(--bg); }
|
||||||
|
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); }
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: wait; opacity: .65; }
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
.brand { color: var(--text); font-weight: 700; text-decoration: none; letter-spacing: -.02em; }
|
||||||
|
|
||||||
|
.site-header { width: min(720px, calc(100% - 32px)); min-height: 64px; margin: 0 auto; display: flex; align-items: center; border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.home-layout { width: min(520px, calc(100% - 32px)); margin: 0 auto; padding: 72px 0; }
|
||||||
|
.home-intro { margin-bottom: 28px; }
|
||||||
|
.home-intro h1 { margin: 0; font-size: clamp(2rem, 7vw, 3rem); letter-spacing: -.04em; }
|
||||||
|
.home-intro p { margin: 10px 0 0; color: var(--muted); line-height: 1.6; }
|
||||||
|
|
||||||
|
.panel { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||||
|
.create-panel { padding: 24px; }
|
||||||
|
#create-form { display: grid; gap: 20px; padding-top: 22px; }
|
||||||
|
.field { display: grid; gap: 8px; }
|
||||||
|
.field label { color: #dce1e8; font-size: .86rem; font-weight: 750; }
|
||||||
|
.label-row, .field-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.label-row span, .field-meta, .field small { color: var(--muted-2); font-size: .73rem; }
|
||||||
|
input, select { border: 1px solid var(--border-strong); outline: none; background: #0e1116; color: var(--text); }
|
||||||
|
input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; }
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; }
|
||||||
|
.password-input { position: relative; }
|
||||||
|
.password-input input { padding-right: 72px; }
|
||||||
|
.text-button { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); border: 0; background: transparent; color: #a99ef8; padding: 7px; font-size: .78rem; }
|
||||||
|
.form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; }
|
||||||
|
.primary-button { width: 100%; border: 1px solid #8372ef; background: var(--accent); color: white; padding: 0 16px; }
|
||||||
|
.primary-button:hover { background: var(--accent-hover); }
|
||||||
|
.secondary-button { border: 1px solid var(--border-strong); background: var(--surface-2); color: #d2d8e1; padding: 0 13px; }
|
||||||
|
.secondary-button:hover { border-color: #4b5565; background: var(--surface-3); }
|
||||||
|
.inline-button { width: auto; margin-top: 18px; padding: 0 16px; }
|
||||||
|
|
||||||
|
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 70px; padding: 0 20px; border-bottom: 1px solid var(--border); background: #0d1015; }
|
||||||
|
.app-header__main, .header-actions { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.header-divider { width: 1px; height: 30px; background: var(--border); }
|
||||||
|
.document-heading { min-width: 0; }
|
||||||
|
.document-heading h1 { overflow: hidden; margin: 0; font-size: 1rem; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.document-url { overflow: hidden; max-width: 360px; margin: 3px 0 0; color: var(--muted-2); font-size: .72rem; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.status { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 8px; color: var(--muted); font-size: .78rem; }
|
||||||
|
.status__dot { width: 7px; height: 7px; border-radius: 50%; background: #e3a94d; }
|
||||||
|
.status__dot.is-online { background: var(--success); }
|
||||||
|
.status__dot.is-offline { background: var(--danger); }
|
||||||
|
|
||||||
|
.editor-layout { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 0; height: calc(100vh - 70px); overflow: hidden; transition: grid-template-columns .18s ease; }
|
||||||
|
.history-open .editor-layout { grid-template-columns: minmax(0, 1fr) 340px; }
|
||||||
|
.editor-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; background: var(--surface); }
|
||||||
|
.editor-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: #101319; }
|
||||||
|
.toolbar-group, .view-switch { display: inline-flex; align-items: center; gap: 4px; padding-right: 8px; border-right: 1px solid var(--border); }
|
||||||
|
.toolbar-group:last-child, .view-switch { padding-right: 0; border-right: 0; }
|
||||||
|
.toolbar-fill { flex: 1; }
|
||||||
|
.editor-toolbar button, .editor-toolbar select { min-height: 34px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #b8c0cc; padding: 0 9px; font-size: .78rem; }
|
||||||
|
.editor-toolbar button:hover, .editor-toolbar select:hover { border-color: var(--border); background: var(--surface-2); color: white; }
|
||||||
|
.editor-toolbar select { border-color: var(--border); background: #11151c; }
|
||||||
|
.view-switch { padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: #0d1015; }
|
||||||
|
.view-switch button.active { background: var(--surface-3); color: white; }
|
||||||
|
|
||||||
|
.workspace { display: grid; min-height: 0; background: #0e1116; }
|
||||||
|
.workspace.view-split { grid-template-columns: 1fr 1fr; }
|
||||||
|
.workspace.view-edit { grid-template-columns: 1fr; }
|
||||||
|
.workspace.view-preview { grid-template-columns: 1fr; }
|
||||||
|
.workspace.view-edit .preview-column, .workspace.view-preview .editor-column { display: none; }
|
||||||
|
.editor-column, .preview-column { display: grid; grid-template-rows: 30px minmax(0, 1fr); min-width: 0; min-height: 0; }
|
||||||
|
.preview-column { border-left: 1px solid var(--border); }
|
||||||
|
.column-label { display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #202631; background: #10141a; color: var(--muted-2); font-size: .7rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
textarea { display: block; width: 100%; height: 100%; min-height: 0; resize: none; padding: 24px; border: 0; outline: none; background: #0d1015; color: #edf1f6; font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; caret-color: #9b89ff; }
|
||||||
|
textarea::placeholder { color: #515a68; }
|
||||||
|
textarea::selection { background: rgba(124,104,238,.35); }
|
||||||
|
.preview { overflow: auto; min-height: 0; padding: 24px; color: #dce2eb; line-height: 1.72; }
|
||||||
|
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 1.25em 0 .5em; letter-spacing: -.03em; }
|
||||||
|
.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; }
|
||||||
|
.markdown-body h1 { font-size: 2rem; }
|
||||||
|
.markdown-body h2 { font-size: 1.5rem; }
|
||||||
|
.markdown-body p { margin: .72em 0; }
|
||||||
|
.markdown-body code { padding: .16em .36em; border: 1px solid #303745; border-radius: 5px; background: #1a2029; }
|
||||||
|
.markdown-body pre { overflow: auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; }
|
||||||
|
.markdown-body pre code { padding: 0; border: 0; background: transparent; }
|
||||||
|
.markdown-body blockquote { margin: 1em 0; padding: .2em 1em; border-left: 3px solid var(--accent); color: #abb5c3; }
|
||||||
|
.markdown-body a { color: #aa9df8; }
|
||||||
|
.markdown-body hr { border: 0; border-top: 1px solid var(--border); }
|
||||||
|
.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 38px; padding: 0 16px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .72rem; }
|
||||||
|
.document-stats { display: flex; gap: 14px; }
|
||||||
|
|
||||||
|
.history-panel { position: relative; width: 340px; overflow: hidden; border-left: 1px solid var(--border); background: #101319; transform: translateX(100%); transition: transform .18s ease; }
|
||||||
|
.history-panel.open { transform: translateX(0); }
|
||||||
|
.history-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; }
|
||||||
|
.history-help { margin: 0; padding: 0 18px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .8rem; line-height: 1.5; }
|
||||||
|
.icon-button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: #c5ccd6; font-size: 1.2rem; }
|
||||||
|
.history-list { overflow: auto; height: calc(100vh - 185px); padding: 10px 18px 24px; }
|
||||||
|
.revision { position: relative; display: grid; grid-template-columns: 12px 1fr; gap: 10px; padding: 13px 0; }
|
||||||
|
.revision::after { content: ""; position: absolute; top: 25px; bottom: -13px; left: 5px; width: 1px; background: var(--border); }
|
||||||
|
.revision:last-child::after { display: none; }
|
||||||
|
.revision__marker { position: relative; z-index: 1; width: 11px; height: 11px; margin-top: 3px; border: 2px solid #8b7af4; border-radius: 50%; background: #101319; }
|
||||||
|
.revision time { display: block; color: #d1d7df; font-size: .8rem; }
|
||||||
|
.revision small { display: block; margin-top: 4px; color: var(--muted-2); }
|
||||||
|
.revision button { margin-top: 10px; border: 0; background: transparent; color: #9e92f5; padding: 0; font-size: .76rem; font-weight: 750; }
|
||||||
|
.empty { padding: 28px 0; color: var(--muted-2); text-align: center; font-size: .82rem; }
|
||||||
|
|
||||||
|
dialog { width: min(420px, calc(100% - 24px)); padding: 0; border: 0; background: transparent; color: inherit; }
|
||||||
|
dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||||
|
.dialog-panel { display: grid; gap: 12px; padding: 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); text-align: left; }
|
||||||
|
.dialog-panel input { margin-top: 6px; }
|
||||||
|
.dialog-panel .primary-button { margin-top: 2px; }
|
||||||
|
.dialog-link { color: var(--muted); font-size: .78rem; }
|
||||||
|
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 20; padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: #171b23; color: #e6eaf0; font-size: .8rem; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .16s ease; }
|
||||||
|
.toast.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.error-page { display: grid; place-items: center; min-height: 100vh; padding: 24px; text-align: center; }
|
||||||
|
.error-page h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -.05em; }
|
||||||
|
.error-page p { color: var(--muted); }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.app-header { align-items: flex-start; min-height: auto; padding: 12px 14px; }
|
||||||
|
.app-header__main, .header-actions { flex-wrap: wrap; }
|
||||||
|
.editor-layout { height: calc(100vh - 92px); }
|
||||||
|
.toolbar-settings { display: none; }
|
||||||
|
.history-open .editor-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.history-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 10; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-header, .home-layout { width: min(100% - 24px, 520px); }
|
||||||
|
.document-url, .header-divider { display: none; }
|
||||||
|
.home-layout { padding: 44px 0; }
|
||||||
|
.app-header { gap: 10px; }
|
||||||
|
.status { display: none; }
|
||||||
|
.editor-toolbar { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
.toolbar-fill { display: none; }
|
||||||
|
.workspace.view-split { grid-template-columns: 1fr; }
|
||||||
|
.workspace.view-split .preview-column { display: none; }
|
||||||
|
.view-switch button[data-view="split"] { display: none; }
|
||||||
|
.preview-column { border-left: 0; }
|
||||||
|
textarea, .preview { padding: 18px; }
|
||||||
|
.editor-footer { align-items: flex-start; flex-direction: column; justify-content: center; gap: 2px; padding: 7px 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.create-panel { padding: 20px; }
|
||||||
|
.header-actions { gap: 6px; }
|
||||||
|
.secondary-button { min-height: 36px; padding: 0 10px; font-size: .75rem; }
|
||||||
|
.history-panel { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-toggle { border: 1px solid var(--border) !important; }
|
||||||
|
.markdown-toggle.active { background: var(--surface-3) !important; color: white !important; }
|
||||||
|
.preview--raw { white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
|
||||||
|
.workspace-page { width: min(1100px, calc(100% - 32px)); margin: 0 auto; padding: 44px 0 80px; }
|
||||||
|
.workspace-top { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||||
|
.workspace-top h2 { margin: 0; font-size: 2rem; }
|
||||||
|
.workspace-top p { margin: 7px 0 0; color: var(--muted); }
|
||||||
|
.notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; padding-top: 20px; }
|
||||||
|
.note-card { min-height: 130px; padding: 18px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); text-decoration: none; transition: border-color .15s, background .15s; }
|
||||||
|
.note-card:hover { border-color: var(--border-strong); background: var(--surface-2); }
|
||||||
|
.note-card h3 { margin: 0; font-size: 1rem; }
|
||||||
|
.note-card p { margin: 36px 0 0; color: var(--muted); font-size: .75rem; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.inline-button { width: auto; padding-inline: 18px; }
|
||||||
|
|
||||||
|
.error-card { width: min(560px, 100%); padding: 32px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||||
|
.error-code { margin: 0 0 10px; color: var(--muted-2); font: 700 .78rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .12em; }
|
||||||
|
.error-card h1 { font-size: clamp(1.8rem, 6vw, 3rem); }
|
||||||
|
.error-card > p:not(.error-code) { max-width: 46ch; margin: 14px auto 0; line-height: 1.6; }
|
||||||
|
.error-actions { display: flex; justify-content: center; gap: 8px; margin-top: 24px; }
|
||||||
|
@media (max-width: 480px) { .error-card { padding: 24px 18px; } .error-actions { flex-direction: column; } .error-actions .inline-button { width: 100%; } }
|
||||||
|
|
||||||
|
/* Home: standalone note and workspace are separate choices. */
|
||||||
|
.home-layout--wide { width: min(1040px, calc(100% - 32px)); }
|
||||||
|
.create-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; }
|
||||||
|
.create-panel__heading { margin-bottom: 22px; }
|
||||||
|
.create-panel__heading h2 { margin: 0; font-size: 1.35rem; }
|
||||||
|
.create-panel__heading p { margin: 7px 0 0; color: var(--muted); line-height: 1.5; }
|
||||||
|
.create-panel form { display: grid; gap: 18px; }
|
||||||
|
.create-panel .primary-button { width: 100%; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.home-layout--wide { width: min(100% - 24px, 560px); }
|
||||||
|
.create-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +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>
|
||||||
Reference in New Issue
Block a user