new functions and fixes
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.45"
|
version = "0.2.46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.45"
|
version = "0.2.46"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
+22
-12
@@ -400,9 +400,9 @@ pub async fn delete_note(
|
|||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
if note.protected {
|
if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::forbidden(
|
||||||
"This note is protected and cannot be deleted",
|
"This note is protected. Only its owner can delete it.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for file in db::list_note_files(&state.db, note.id).await? {
|
for file in db::list_note_files(&state.db, note.id).await? {
|
||||||
@@ -478,20 +478,30 @@ pub async fn delete_note_file(
|
|||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let workspace_owner = crate::auth::is_resource_owner(
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
|
{
|
||||||
|
AccessLevel::Write
|
||||||
|
} else {
|
||||||
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace.slug,
|
&workspace_slug,
|
||||||
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await
|
.await?
|
||||||
.unwrap_or(false);
|
};
|
||||||
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
require_write(level)?;
|
||||||
let password_write_access =
|
if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? {
|
||||||
has_password_write_access(&state, &headers, "workspace", &workspace.slug).await?;
|
|
||||||
if !workspace_owner && !note_owner && !password_write_access {
|
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Only the note owner, workspace owner, or password holder can delete files",
|
"This note is protected. Only its owner can delete files.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let file = db::find_note_file(&state.db, note.id, file_id)
|
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||||
|
|||||||
+79
-15
@@ -136,6 +136,25 @@ fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace
|
|||||||
guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref())
|
guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn requester_owns_note(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
workspace: &db::Workspace,
|
||||||
|
note: &db::Note,
|
||||||
|
) -> Result<bool, ApiError> {
|
||||||
|
let workspace_account_owner = crate::auth::is_resource_owner(
|
||||||
|
state,
|
||||||
|
"workspace",
|
||||||
|
&workspace.slug,
|
||||||
|
user_session_token(headers),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(workspace_account_owner
|
||||||
|
|| workspace_creator_is_requester(headers, workspace)
|
||||||
|
|| note_creator_is_requester(state, headers, note).await?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn can_set_workspace_password(
|
async fn can_set_workspace_password(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -399,6 +418,7 @@ pub struct NoteListItem {
|
|||||||
updated_at: String,
|
updated_at: String,
|
||||||
url: String,
|
url: String,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
|
can_delete: bool,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
participant_count: i64,
|
participant_count: i64,
|
||||||
file_count: i64,
|
file_count: i64,
|
||||||
@@ -421,6 +441,7 @@ pub struct NoteInfo {
|
|||||||
private: bool,
|
private: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
|
can_delete: bool,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
can_upload_files: bool,
|
can_upload_files: bool,
|
||||||
upload_max_size_bytes: Option<usize>,
|
upload_max_size_bytes: Option<usize>,
|
||||||
@@ -741,6 +762,32 @@ pub async fn open_workspace(
|
|||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let mut access_level = effective_header_access_level(
|
||||||
|
&state,
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace.slug,
|
||||||
|
workspace.is_private,
|
||||||
|
workspace.password_hash.is_some(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
||||||
|
access_level = AccessLevel::Write;
|
||||||
|
}
|
||||||
|
let workspace_account_owner = crate::auth::is_resource_owner(
|
||||||
|
&state,
|
||||||
|
"workspace",
|
||||||
|
&workspace.slug,
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
let workspace_owner =
|
||||||
|
workspace_account_owner || workspace_creator_is_requester(&headers, &workspace);
|
||||||
|
let requester_user = session_user(&state, &headers).await?;
|
||||||
|
let requester_nickname = requester_user.as_ref().map(|user| user.nickname.as_str());
|
||||||
|
let requester_guest = requester_guest_id(&headers);
|
||||||
|
|
||||||
let stats = db::list_note_stats(&state.db, workspace.id)
|
let stats = db::list_note_stats(&state.db, workspace.id)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -763,6 +810,19 @@ pub async fn open_workspace(
|
|||||||
})
|
})
|
||||||
.map(|note| {
|
.map(|note| {
|
||||||
let stats = stats.get(¬e.id);
|
let stats = stats.get(¬e.id);
|
||||||
|
let note_owner = if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
|
||||||
|
requester_guest.is_some_and(|requester| requester == owner_guest_id)
|
||||||
|
} else {
|
||||||
|
note.created_by
|
||||||
|
.as_deref()
|
||||||
|
.zip(requester_nickname)
|
||||||
|
.is_some_and(|(owner, requester)| owner == requester)
|
||||||
|
};
|
||||||
|
let can_delete = can_delete_workspace_note(
|
||||||
|
access_level,
|
||||||
|
note.protected,
|
||||||
|
workspace_owner || note_owner,
|
||||||
|
);
|
||||||
NoteListItem {
|
NoteListItem {
|
||||||
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
||||||
slug: note.slug,
|
slug: note.slug,
|
||||||
@@ -770,6 +830,7 @@ pub async fn open_workspace(
|
|||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
|
can_delete,
|
||||||
created_by: note.created_by,
|
created_by: note.created_by,
|
||||||
participant_count: stats.map_or(0, |value| value.participant_count),
|
participant_count: stats.map_or(0, |value| value.participant_count),
|
||||||
file_count: stats.map_or(0, |value| value.file_count),
|
file_count: stats.map_or(0, |value| value.file_count),
|
||||||
@@ -788,18 +849,6 @@ pub async fn open_workspace(
|
|||||||
let start = (page - 1) * per_page;
|
let start = (page - 1) * per_page;
|
||||||
let notes = notes.into_iter().skip(start).take(per_page).collect();
|
let notes = notes.into_iter().skip(start).take(per_page).collect();
|
||||||
|
|
||||||
let mut access_level = effective_header_access_level(
|
|
||||||
&state,
|
|
||||||
&headers,
|
|
||||||
"workspace",
|
|
||||||
&workspace.slug,
|
|
||||||
workspace.is_private,
|
|
||||||
workspace.password_hash.is_some(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
|
||||||
access_level = AccessLevel::Write;
|
|
||||||
}
|
|
||||||
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
|
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
|
||||||
Ok(Json(WorkspaceOpenResponse {
|
Ok(Json(WorkspaceOpenResponse {
|
||||||
workspace: workspace_info_from(&workspace, access_level, can_set_password),
|
workspace: workspace_info_from(&workspace, access_level, can_set_password),
|
||||||
@@ -911,6 +960,7 @@ pub async fn create_note(
|
|||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
|
can_delete: true,
|
||||||
created_by: note.created_by,
|
created_by: note.created_by,
|
||||||
participant_count: 0,
|
participant_count: 0,
|
||||||
file_count: 0,
|
file_count: 0,
|
||||||
@@ -1050,9 +1100,14 @@ pub async fn note_info(
|
|||||||
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||||
let password_write_access =
|
let password_write_access =
|
||||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
let can_manage_authorship =
|
let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner;
|
||||||
can_manage_resource_settings(workspace_owner, note_owner, password_write_access);
|
let can_manage_authorship = can_manage_resource_settings(
|
||||||
let can_delete_files = can_manage_authorship;
|
workspace_owner || workspace_guest_owner,
|
||||||
|
note_owner,
|
||||||
|
password_write_access,
|
||||||
|
);
|
||||||
|
let can_delete_files =
|
||||||
|
can_delete_workspace_note(access_level, note.protected, requester_is_owner);
|
||||||
let upload_max_size_bytes =
|
let upload_max_size_bytes =
|
||||||
resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?;
|
resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
let can_upload_files = upload_max_size_bytes.is_some();
|
let can_upload_files = upload_max_size_bytes.is_some();
|
||||||
@@ -1080,6 +1135,7 @@ pub async fn note_info(
|
|||||||
private: workspace.is_private != 0,
|
private: workspace.is_private != 0,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
|
can_delete: can_delete_files,
|
||||||
can_delete_files,
|
can_delete_files,
|
||||||
can_upload_files,
|
can_upload_files,
|
||||||
upload_max_size_bytes,
|
upload_max_size_bytes,
|
||||||
@@ -1404,6 +1460,14 @@ async fn effective_header_access_level(
|
|||||||
Ok(level)
|
Ok(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn can_delete_workspace_note(
|
||||||
|
level: AccessLevel,
|
||||||
|
note_protected: bool,
|
||||||
|
requester_is_owner: bool,
|
||||||
|
) -> bool {
|
||||||
|
level >= AccessLevel::Write && (!note_protected || requester_is_owner)
|
||||||
|
}
|
||||||
|
|
||||||
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
|
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
|
||||||
if level >= AccessLevel::Write {
|
if level >= AccessLevel::Write {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -51,3 +51,12 @@ fn settings_allow_owner_or_verified_password_holder() {
|
|||||||
assert!(can_manage_resource_settings(false, false, true));
|
assert!(can_manage_resource_settings(false, false, true));
|
||||||
assert!(!can_manage_resource_settings(false, false, false));
|
assert!(!can_manage_resource_settings(false, false, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_note_deletion_follows_rw_protection_rules() {
|
||||||
|
assert!(can_delete_workspace_note(AccessLevel::Write, false, false));
|
||||||
|
assert!(can_delete_workspace_note(AccessLevel::Write, true, true));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::Write, true, false));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::Read, false, true));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::None, false, true));
|
||||||
|
}
|
||||||
|
|||||||
+23
-1
@@ -2510,6 +2510,17 @@ dialog::backdrop {
|
|||||||
min-width: 74px;
|
min-width: 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-delete-notice {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.file-actions {
|
.file-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2567,13 +2578,24 @@ dialog::backdrop {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-choice-actions strong {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.video-choice-actions span {
|
.video-choice-actions span {
|
||||||
color: var(--muted);
|
|
||||||
font-size: .76rem;
|
font-size: .76rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-choice-actions > .action-button--primary span {
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-choice-actions > .action-button--secondary span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.video-choice-actions {
|
.video-choice-actions {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
|
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
|
||||||
function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; }
|
function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; }
|
||||||
|
function imageNeedsProcessing(width, height, aspect, maxSize) {
|
||||||
|
return aspect !== "original" || (maxSize > 0 && Math.max(width, height) > maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareImageFile(file) {
|
export async function prepareImageFile(file) {
|
||||||
if (!file.type.startsWith("image/")) return file;
|
if (!file.type.startsWith("image/")) return file;
|
||||||
@@ -61,8 +64,15 @@ export async function prepareImageFile(file) {
|
|||||||
const result = new Promise(resolve => {
|
const result = new Promise(resolve => {
|
||||||
dialog.addEventListener("close", () => { URL.revokeObjectURL(url); dialog.remove(); resolve(accepted); }, { once: true });
|
dialog.addEventListener("close", () => { URL.revokeObjectURL(url); dialog.remove(); resolve(accepted); }, { once: true });
|
||||||
dialog.querySelector("[data-apply]").addEventListener("click", async () => {
|
dialog.querySelector("[data-apply]").addEventListener("click", async () => {
|
||||||
const box = cropBox(), maxSize = Number(sizeSelect.value), out = document.createElement("canvas");
|
const box = cropBox(), maxSize = Number(sizeSelect.value);
|
||||||
if (aspectSelect.value === "original") {
|
const wholeImage = aspectSelect.value === "original";
|
||||||
|
if (!imageNeedsProcessing(image.naturalWidth, image.naturalHeight, aspectSelect.value, maxSize)) {
|
||||||
|
accepted = file;
|
||||||
|
dialog.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const out = document.createElement("canvas");
|
||||||
|
if (wholeImage) {
|
||||||
const ratio = Math.min(1, maxSize ? maxSize / Math.max(image.naturalWidth, image.naturalHeight) : 1);
|
const ratio = Math.min(1, maxSize ? maxSize / Math.max(image.naturalWidth, image.naturalHeight) : 1);
|
||||||
out.width = Math.max(1, Math.round(image.naturalWidth * ratio)); out.height = Math.max(1, Math.round(image.naturalHeight * ratio));
|
out.width = Math.max(1, Math.round(image.naturalWidth * ratio)); out.height = Math.max(1, Math.round(image.naturalHeight * ratio));
|
||||||
out.getContext("2d").drawImage(image, 0, 0, out.width, out.height);
|
out.getContext("2d").drawImage(image, 0, 0, out.width, out.height);
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
}),
|
}),
|
||||||
configureView(info) {
|
configureView(info) {
|
||||||
const button = document.querySelector("#delete-note");
|
const button = document.querySelector("#delete-note");
|
||||||
if (button) button.hidden = info.note_protected;
|
if (!button) return;
|
||||||
|
button.hidden = !info.can_delete;
|
||||||
|
button.title = info.note_protected
|
||||||
|
? "Delete protected note (owner only)"
|
||||||
|
: "Delete note";
|
||||||
},
|
},
|
||||||
async deleteNote(info, accessToken) {
|
async deleteNote(info, accessToken) {
|
||||||
if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, {
|
if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, {
|
||||||
|
|||||||
@@ -1354,6 +1354,14 @@ export function startNoteEditor(adapter) {
|
|||||||
editor, toast, getAccessToken: () => accessToken,
|
editor, toast, getAccessToken: () => accessToken,
|
||||||
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
||||||
canDelete: () => Boolean(info?.can_delete_files),
|
canDelete: () => Boolean(info?.can_delete_files),
|
||||||
|
getDeleteRestrictionMessage: () => {
|
||||||
|
if (info?.note_protected && !info?.can_delete_files) {
|
||||||
|
return "This note is protected. Only its owner can delete files.";
|
||||||
|
}
|
||||||
|
if (info?.access_level !== "write") return "Read-write access is required to delete files.";
|
||||||
|
if (!info?.can_delete_files) return "Only the note owner can delete files.";
|
||||||
|
return "";
|
||||||
|
},
|
||||||
canUpload: () => Boolean(info?.can_upload_files),
|
canUpload: () => Boolean(info?.can_upload_files),
|
||||||
canEdit: canEditDocument,
|
canEdit: canEditDocument,
|
||||||
endpoints: adapter.fileEndpoints,
|
endpoints: adapter.fileEndpoints,
|
||||||
|
|||||||
+21
-3
@@ -79,6 +79,17 @@ function safeAttachmentUrl(value) {
|
|||||||
: safePublicUrl(raw, { allowMailto: false });
|
: safePublicUrl(raw, { allowMailto: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadAttachmentUrl(value) {
|
||||||
|
const safe = safeAttachmentUrl(value);
|
||||||
|
try {
|
||||||
|
const url = new URL(safe, location.origin);
|
||||||
|
if (/^\/f\/[^/]+\/[^/]+$/.test(url.pathname)) url.searchParams.set("download", "1");
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createVideoInsertDialog() {
|
function createVideoInsertDialog() {
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
dialog.className = "app-dialog video-insert-dialog";
|
dialog.className = "app-dialog video-insert-dialog";
|
||||||
@@ -102,7 +113,7 @@ function createVideoInsertDialog() {
|
|||||||
return dialog;
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
|
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", toast, onFilesChanged = () => { } }) {
|
||||||
const dialog = document.querySelector("#files-dialog");
|
const dialog = document.querySelector("#files-dialog");
|
||||||
const list = document.querySelector("#files-list");
|
const list = document.querySelector("#files-list");
|
||||||
const input = document.querySelector("#file-input");
|
const input = document.querySelector("#file-input");
|
||||||
@@ -163,7 +174,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||||
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
||||||
list.innerHTML = files.length ? files.map(file => `
|
const restrictionMessage = getDeleteRestrictionMessage();
|
||||||
|
const restrictionNotice = restrictionMessage
|
||||||
|
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
|
||||||
|
: "";
|
||||||
|
const fileRows = files.length ? files.map(file => `
|
||||||
<div class="file-row" data-file-row="${file.id}">
|
<div class="file-row" data-file-row="${file.id}">
|
||||||
<div class="file-row-main">
|
<div class="file-row-main">
|
||||||
<div class="file-name">${escapeHtml(file.filename)}</div>
|
<div class="file-name">${escapeHtml(file.filename)}</div>
|
||||||
@@ -172,6 +187,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
<div class="file-actions">${fileActionButtons(file)}</div>
|
<div class="file-actions">${fileActionButtons(file)}</div>
|
||||||
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
|
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
|
||||||
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
||||||
|
list.innerHTML = restrictionNotice + fileRows;
|
||||||
onFilesChanged(files);
|
onFilesChanged(files);
|
||||||
if (open && !dialog.open) dialog.showModal();
|
if (open && !dialog.open) dialog.showModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -314,7 +330,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
const output = panel.querySelector("textarea");
|
const output = panel.querySelector("textarea");
|
||||||
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
|
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
|
||||||
const absolute = new URL(safeUrl, location.origin).href;
|
const absolute = new URL(safeUrl, location.origin).href;
|
||||||
let text = absolute;
|
let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime)
|
||||||
|
? downloadAttachmentUrl(showButton.dataset.url)
|
||||||
|
: absolute;
|
||||||
if (showButton.dataset.showFileCode === "alias") {
|
if (showButton.dataset.showFileCode === "alias") {
|
||||||
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
|
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
|
||||||
} else if (showButton.dataset.showFileCode === "markdown") {
|
} else if (showButton.dataset.showFileCode === "markdown") {
|
||||||
|
|||||||
@@ -165,9 +165,13 @@ function setNotesView(view) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function deleteButton(note, inline = false) {
|
function deleteButton(note, inline = false) {
|
||||||
const disabled = note.protected;
|
const disabled = !note.can_delete;
|
||||||
const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`;
|
const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`;
|
||||||
const reason = disabled ? "Protected notes cannot be deleted" : `Delete ${note.title}`;
|
const reason = !disabled
|
||||||
|
? `Delete ${note.title}`
|
||||||
|
: note.protected
|
||||||
|
? "Protected note: only its owner can delete it"
|
||||||
|
: "Read-write access is required to delete this note";
|
||||||
return `<button class="${classes}" data-delete-note="${escapeHtml(note.slug)}" data-note-title="${escapeHtml(note.title)}" ${disabled ? "disabled" : ""} title="${escapeHtml(reason)}">Delete</button>`;
|
return `<button class="${classes}" data-delete-note="${escapeHtml(note.slug)}" data-note-title="${escapeHtml(note.title)}" ${disabled ? "disabled" : ""} title="${escapeHtml(reason)}">Delete</button>`;
|
||||||
}
|
}
|
||||||
function renderNotes(notes = notesCache) {
|
function renderNotes(notes = notesCache) {
|
||||||
|
|||||||
Reference in New Issue
Block a user