From ec9f002b817fcd09b08940972e8ad81aef58ce4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 22 Jul 2026 13:04:21 +0200 Subject: [PATCH] fixes and my notes function --- Cargo.lock | 6 +- Cargo.toml | 8 +-- migrations/mysql/0007_user_resources.sql | 14 ++++ migrations/postgres/0007_user_resources.sql | 10 +++ migrations/sqlite/0007_user_resources.sql | 10 +++ src/api.rs | 14 +++- src/app.rs | 1 + src/auth.rs | 42 +++++++++++ src/queries.rs | 11 +++ static/css/styles.css | 25 +++++++ static/home.html | 14 +++- static/js/auth-ui.js | 5 +- static/js/home.js | 78 ++++++++++++++++++++- 13 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 migrations/mysql/0007_user_resources.sql create mode 100644 migrations/postgres/0007_user_resources.sql create mode 100644 migrations/sqlite/0007_user_resources.sql diff --git a/Cargo.lock b/Cargo.lock index e1265f0..78923cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.3" +version = "0.0.4" dependencies = [ "argon2", "axum", @@ -2026,9 +2026,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ "bitflags", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1278b50..69da125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.4" +version = "0.0.5" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" @@ -18,10 +18,10 @@ sha2 = "0.10" rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -slug = "0.1" +slug = "0.1.6" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } +tower = "0.5.3" +tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/migrations/mysql/0007_user_resources.sql b/migrations/mysql/0007_user_resources.sql new file mode 100644 index 0000000..8e52365 --- /dev/null +++ b/migrations/mysql/0007_user_resources.sql @@ -0,0 +1,14 @@ +CREATE TABLE user_workspaces ( + user_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + PRIMARY KEY (user_id, workspace_id), + CONSTRAINT fk_user_workspaces_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_workspaces_workspace FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); +CREATE TABLE user_pads ( + user_id BIGINT NOT NULL, + pad_id BIGINT NOT NULL, + PRIMARY KEY (user_id, pad_id), + CONSTRAINT fk_user_pads_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_pads_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE +); diff --git a/migrations/postgres/0007_user_resources.sql b/migrations/postgres/0007_user_resources.sql new file mode 100644 index 0000000..40493a2 --- /dev/null +++ b/migrations/postgres/0007_user_resources.sql @@ -0,0 +1,10 @@ +CREATE TABLE user_workspaces ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workspace_id) +); +CREATE TABLE user_pads ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pad_id BIGINT NOT NULL REFERENCES pads(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, pad_id) +); diff --git a/migrations/sqlite/0007_user_resources.sql b/migrations/sqlite/0007_user_resources.sql new file mode 100644 index 0000000..985f498 --- /dev/null +++ b/migrations/sqlite/0007_user_resources.sql @@ -0,0 +1,10 @@ +CREATE TABLE user_workspaces ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workspace_id) +); +CREATE TABLE user_pads ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pad_id INTEGER NOT NULL REFERENCES pads(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, pad_id) +); diff --git a/src/api.rs b/src/api.rs index 13ae536..8a9974e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Multipart, Path, State}, - http::{header, HeaderValue, StatusCode}, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -122,13 +122,17 @@ pub struct NoteInfo { pub async fn create_workspace( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { let title = validate_name(&payload.name, "Workspace name")?; 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?; + let workspace = db::create_workspace(&state.db, &slug, title, password).await?; + if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { + sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_WORKSPACE)).bind(user.id).bind(&workspace.slug).execute(state.db.pool()).await?; + } Ok(( StatusCode::CREATED, @@ -398,6 +402,7 @@ pub struct PadInfo { pub async fn create_pad( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { let title = validate_name(&payload.name, "Note name")?; @@ -407,7 +412,10 @@ pub async fn create_pad( return Err(ApiError::bad_request("The name cannot be converted into a valid address")); } let slug = unique_pad_slug(&state, &base).await?; - db::create_pad(&state.db, &slug, title, password).await?; + let pad = db::create_pad(&state.db, &slug, title, password).await?; + if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { + sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)).bind(user.id).bind(&pad.slug).execute(state.db.pool()).await?; + } Ok(( StatusCode::CREATED, Json(CreatePadResponse { diff --git a/src/app.rs b/src/app.rs index 7bab803..2f9311e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,6 +25,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/auth/login", post(auth::login)) .route("/api/auth/me", get(auth::me)) .route("/api/auth/logout", post(auth::logout)) + .route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource)) .route("/api/auth/password-reset", post(auth::request_reset)) .route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) .route("/api/public/{token}", get(api::public_page)) diff --git a/src/auth.rs b/src/auth.rs index 7479e67..4694c0d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -22,6 +22,9 @@ pub struct User { pub id: i64, pub nickname: String, pub email: String, pub pass #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String } #[derive(Deserialize)] pub struct ResetRequest { email: String } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String } +#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option } +#[derive(Serialize, FromRow)] pub struct ResourceItem { slug: String, title: String, protected: i64, updated_at: String } +#[derive(Serialize)] pub struct ResourceList { workspaces: Vec, pads: Vec } #[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String } #[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool } @@ -79,6 +82,45 @@ pub async fn me(State(state): State, headers: HeaderMap) -> Result< Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at })) } +pub async fn resources(State(state): State, headers: HeaderMap) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(ResourceList { workspaces, pads })) +} + +pub async fn update_resource(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + let hash = match req.password.as_deref().map(str::trim).filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) }, None => None }; + ensure_owner(&state, user.id, &req.kind, &req.slug).await?; + let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; + sqlx::query(queries::get(state.db.kind(), query)).bind(hash).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok":true}))) +} + +pub async fn delete_resource(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + ensure_owner(&state, user.id, &req.kind, &req.slug).await?; + let query = match req.kind.as_str() { "workspace" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; + sqlx::query(queries::get(state.db.kind(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok":true}))) +} + +pub async fn optional_user(state: &SharedState, headers: &HeaderMap) -> Result, AuthError> { + match bearer(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None) } +} + +async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result { + optional_user(state, headers).await?.ok_or_else(|| AuthError::unauthorized("Log in first.")) +} + +async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str) -> Result<(), AuthError> { + let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)).bind(user_id).bind(slug.trim()).fetch_one(state.db.pool()).await.map_err(AuthError::database)?; + if count == 0 { return Err(AuthError::forbidden("This item does not belong to your account.")); } + Ok(()) +} + pub async fn logout(State(state): State, headers: HeaderMap) -> Result, AuthError> { if let Some(token) = bearer(&headers) { let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; diff --git a/src/queries.rs b/src/queries.rs index 174fc66..570e9dd 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -25,6 +25,17 @@ pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.pass pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?"; pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?"; +pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"; +pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; +pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC"; +pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? ORDER BY p.updated_at DESC"; +pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"; +pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"; +pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?"; +pub const USER_DELETE_PAD: &str = "DELETE FROM pads WHERE slug = ?"; +pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; + pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; diff --git a/static/css/styles.css b/static/css/styles.css index 7a101f8..eb3b255 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -524,3 +524,28 @@ dialog::backdrop { background: rgba(4,6,9,.82); } [hidden] { display: none !important; } .identity-panel #auth-panel:not([hidden]) { margin-top: 4px; } .identity-panel #nickname:disabled { opacity: .55; cursor: not-allowed; } + +#resources-dialog { width: min(760px, calc(100% - 28px)); max-width: 760px; } +.resources-panel { position: relative; box-sizing: border-box; width: 100%; min-width: 0; max-height: 80vh; overflow-x: hidden; overflow-y: auto; } +.resources-panel__header { min-width: 0; padding-right: 38px; } +.resources-panel__header h2 { margin: 0 0 7px; overflow-wrap: anywhere; } +.resources-panel__header p { margin: 0; overflow-wrap: anywhere; } +.resources-list { display: grid; min-width: 0; gap: 10px; margin-top: 18px; } +.resource-row { display:flex; min-width:0; justify-content:space-between; gap:16px; align-items:center; padding:12px; border:1px solid var(--border); border-radius:10px; } +.resource-row > :first-child { min-width: 0; } +.resource-row a { display: block; max-width: 100%; font-weight:700; overflow-wrap:anywhere; word-break:break-word; } +.resource-row small { display:block; max-width:100%; margin-top:4px; color:var(--muted-2); overflow-wrap:anywhere; } +.resource-actions { display:flex; flex:0 0 auto; gap:8px; flex-wrap:wrap; } +.resource-actions button { padding:7px 10px; } +@media (max-width:640px){.resource-row{align-items:flex-start;flex-direction:column}.resource-actions{width:100%}} + +.resource-main { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; } +.resource-inline { width: 100%; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.resource-password-form label { display: grid; gap: 6px; font-size: .8rem; } +.resource-password-form input { width: 100%; } +.resource-inline-help { margin: 6px 0 0; color: var(--muted); font-size: .75rem; } +.resource-inline-message { margin: 8px 0 0; } +.resource-inline-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } +.resource-delete-confirm > p:first-child { margin: 0; } +@media (max-width: 640px) { .resource-main { align-items: flex-start; flex-direction: column; } .resource-actions { width: 100%; } } +.resource-row { align-items: stretch; flex-direction: column; } diff --git a/static/home.html b/static/home.html index ec2e945..dee477b 100644 --- a/static/home.html +++ b/static/home.html @@ -67,6 +67,7 @@ Author: @linuxiarz.pl @@ -81,7 +82,7 @@

- +
@@ -94,5 +95,16 @@

+ +
+ +
+

My notes and workspaces

+

Items created while signed in are assigned to your account.

+
+
+ +
+
diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 9ce0929..8a09a00 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -49,11 +49,14 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } switchMode.textContent = registering ? "Already registered? Log in" : "Create an account"; resetButton.hidden = resetting || registering; backButton.hidden = !resetting; + form.autocomplete = resetting ? "off" : "on"; + nickname.autocomplete = "nickname"; + email.autocomplete = "username"; password.autocomplete = registering ? "new-password" : "current-password"; message.textContent = ""; queueMicrotask(() => { - email.focus(); + (registering ? nickname : email).focus(); }); }; diff --git a/static/js/home.js b/static/js/home.js index 7fb2e9b..55f561a 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -2,6 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "./logger.js"; installGlobalDiagnostics(); import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js"; +import { getAuthToken } from "@rustpad/session"; import { api } from "@rustpad/api"; function slugify(value, fallback) { @@ -48,7 +49,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) => 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) }); + const result = await api("/api/pads", { method: "POST", headers: authHeaders(), 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) { @@ -69,7 +70,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even 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) }); + const result = await api("/api/workspaces", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) }); if (password.value) sessionStorage.setItem(`rustpad:workspace:${result.slug}:password`, password.value); window.location.assign(result.url); } catch (requestError) { @@ -87,6 +88,76 @@ const userAccount = document.querySelector("#footer-account-user"); const userLabel = document.querySelector("#footer-user-label"); const registerLink = document.querySelector("#footer-register"); const registrationEnabled = document.body.dataset.registrationEnabled === "true"; +const resourcesDialog = document.querySelector("#resources-dialog"); +const resourcesList = document.querySelector("#resources-list"); +const resourcesError = document.querySelector("#resources-error"); + +function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } +async function loadResources() { + resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading…

"; + try { + const data = await api("/api/auth/resources", { headers: authHeaders() }); + const items = [...data.workspaces.map(item => ({...item, kind:"workspace", url:`/w/${item.slug}`})), ...data.pads.map(item => ({...item, kind:"pad", url:`/p/${item.slug}`}))]; + resourcesList.innerHTML = items.length ? "" : "

No assigned items yet.

"; + for (const item of items) { + const row = document.createElement("article"); + row.className = "resource-row"; + row.innerHTML = `
${item.title}${item.kind === "workspace" ? "Workspace" : "Note"}${item.protected ? " · password protected" : ""}
`; + + const inline = row.querySelector("[data-inline]"); + const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; }; + const setInlineMessage = (text, type = "") => { + const message = inline.querySelector("[data-inline-message]"); + if (!message) return; + message.className = `form-message resource-inline-message ${type}`.trim(); + message.textContent = text; + }; + + row.querySelector("[data-password]").addEventListener("click", () => { + inline.hidden = false; + inline.innerHTML = `

Leave empty to remove password protection.

`; + const form = inline.querySelector("form"); + const input = form.querySelector("input"); + form.querySelector("[data-cancel]").addEventListener("click", closeInline); + form.addEventListener("submit", async event => { + event.preventDefault(); + const password = input.value; + if (password && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; } + const submit = form.querySelector('[type="submit"]'); + submit.disabled = true; + setInlineMessage(""); + try { + await api("/api/auth/resources", { method:"PUT", headers:authHeaders(), body:JSON.stringify({kind:item.kind, slug:item.slug, password}) }); + await loadResources(); + } catch(e) { + setInlineMessage(e.message, "error"); + submit.disabled = false; + } + }); + input.focus(); + }); + + row.querySelector("[data-delete]").addEventListener("click", () => { + inline.hidden = false; + inline.innerHTML = `

Delete “${item.title}” permanently?

`; + inline.querySelector("[data-cancel]").addEventListener("click", closeInline); + inline.querySelector("[data-confirm-delete]").addEventListener("click", async event => { + event.currentTarget.disabled = true; + setInlineMessage(""); + try { + await api("/api/auth/resources", { method:"DELETE", headers:authHeaders(), body:JSON.stringify({kind:item.kind, slug:item.slug}) }); + await loadResources(); + } catch(e) { + setInlineMessage(e.message, "error"); + event.currentTarget.disabled = false; + } + }); + }); + resourcesList.append(row); + } + } catch(e) { resourcesList.innerHTML=""; resourcesError.textContent=e.message; } +} + function renderAccount(session) { guestAccount.hidden = Boolean(session); @@ -109,6 +180,9 @@ if (identityDialog) { authDialog.setMode("register"); identityDialog.showModal(); }); + document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); }); + document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close()); + resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); }); document.querySelector("#footer-logout")?.addEventListener("click", async () => { await logoutCurrentSession(); renderAccount(null);