This commit is contained in:
Mateusz Gruszczyński
2026-09-20 14:24:34 +02:00
parent 0a269cf6fb
commit e9f1dc515c
8 changed files with 544 additions and 126 deletions
+144 -34
View File
@@ -7,7 +7,7 @@
* See LICENSE file in repository root for details.
*/
import { formatDateTime, formatNumber, tp } from "@rustpad/i18n";
import { formatDateTime, formatNumber, t, tp } from "@rustpad/i18n";
import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
@@ -153,48 +153,140 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
});
}
function fileTypeBadge(file) {
const filename = String(file?.filename || "");
const extension = filename.includes(".") ? filename.split(".").pop().replace(/[^a-z0-9]/gi, "").slice(0, 4) : "";
if (extension) return extension.toUpperCase();
const major = String(file?.mime_type || "file").split("/", 1)[0];
return ({ image: "IMG", video: "VID", audio: "AUD", text: "TXT", application: "FILE" })[major] || "FILE";
}
function fileActionButtons(file) {
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
const insertButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="player" ${attributes}>Add player</button>
<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>Add download</button>`
: `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="auto" ${attributes}>Add to note</button>`;
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>Player code</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>Download code</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" ${attributes}>Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" ${attributes}>Markdown</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`
const primaryInsert = isVideo(file.mime_type)
? `<button class="action-button action-button--primary compact-button file-primary-action" data-add-file-to-note data-insert-mode="player" ${attributes}>${escapeHtml(t("files.addPlayer", {}, "Add player"))}</button>`
: `<button class="action-button action-button--primary compact-button file-primary-action" data-add-file-to-note data-insert-mode="auto" ${attributes}>${escapeHtml(t("files.addToNote", {}, "Add to note"))}</button>`;
const secondaryInsert = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>${escapeHtml(t("files.addDownload", {}, "Add download"))}</button>`
: "";
return `${codeButtons}${insertButtons}${deleteButton}`;
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>${escapeHtml(t("files.playerCode", {}, "Player code"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>${escapeHtml(t("files.downloadCode", {}, "Download code"))}</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="alias" ${attributes}>${escapeHtml(t("files.alias", {}, "Alias"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="markdown" ${attributes}>${escapeHtml(t("files.markdown", {}, "Markdown"))}</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">${escapeHtml(t("files.delete", {}, "Delete file"))}</button>`
: "";
return {
primary: `${primaryInsert}<button class="action-button action-button--secondary compact-button file-more-button" type="button" data-toggle-file-actions aria-expanded="false">${escapeHtml(t("common.more", {}, "More"))}</button>`,
secondary: `${secondaryInsert}${codeButtons}${deleteButton}`,
};
}
const summary = document.querySelector("#files-dialog-summary");
const search = document.querySelector("#files-search");
let currentFiles = [];
let filesLoaded = false;
let loadSequence = 0;
function summaryText(files, shown = files.length) {
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
if (shown !== files.length) {
return t("files.filteredSummary", { shown, count: files.length, size: formatBytes(totalSize) }, `${shown} of ${files.length} files · ${formatBytes(totalSize)}`);
}
return tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
}
function fileSearchText(file) {
return `${file.filename || ""} ${file.mime_type || ""} ${fileTypeBadge(file)}`.toLocaleLowerCase();
}
function fileRow(file) {
const created = file.created_at ? formatDate(file.created_at) : "";
const status = file.is_attached ? t("files.inNote", {}, "in note") : t("files.removedContent", {}, "removed from content");
const generatedCodeLabel = t("files.generatedCode", {}, "Generated file code");
const actions = fileActionButtons(file);
return `
<article class="file-row" data-file-row="${file.id}" data-file-search="${escapeHtml(fileSearchText(file))}">
<div class="file-row-main">
<div class="file-type-badge" aria-hidden="true">${escapeHtml(fileTypeBadge(file))}</div>
<div class="file-copy">
<div class="file-name" title="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</div>
<div class="file-meta">
<span>${formatBytes(file.size_bytes)}</span>
<span>${escapeHtml(file.mime_type || "file")}</span>
${created ? `<span>${escapeHtml(created)}</span>` : ""}
<span class="file-flag${file.is_attached ? "" : " detached"}">${escapeHtml(status)}</span>
</div>
</div>
</div>
<div class="file-actions">${actions.primary}</div>
<div class="file-secondary-actions" hidden>${actions.secondary}</div>
<div class="file-code" hidden><textarea readonly aria-label="${escapeHtml(generatedCodeLabel)}"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>${escapeHtml(t("share.copy", {}, "Copy"))}</button></div>
</article>`;
}
function renderFiles() {
if (!filesLoaded) return;
if (search) search.disabled = false;
const query = String(search?.value || "").trim().toLocaleLowerCase();
const restrictionMessage = getDeleteRestrictionMessage();
const restrictionNotice = restrictionMessage
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
: "";
const visible = query ? currentFiles.filter(file => fileSearchText(file).includes(query)) : currentFiles;
if (summary) summary.textContent = summaryText(currentFiles, visible.length);
if (!currentFiles.length) {
list.innerHTML = `${restrictionNotice}<div class="files-state files-state--empty"><div class="files-state-mark" aria-hidden="true">+</div><strong>${escapeHtml(t("files.empty", {}, "No files uploaded."))}</strong><span>${escapeHtml(t("files.emptyHelp", {}, "Upload a file to keep it with this note and insert it into the document."))}</span></div>`;
return;
}
if (!visible.length) {
list.innerHTML = `${restrictionNotice}<div class="files-state files-state--empty"><div class="files-state-mark files-state-mark--search" aria-hidden="true">⌕</div><strong>${escapeHtml(t("files.noMatches", {}, "No files match this filter."))}</strong><span>${escapeHtml(t("files.noMatchesHelp", {}, "Try a different file name or type."))}</span></div>`;
return;
}
list.innerHTML = restrictionNotice + visible.map(fileRow).join("");
}
function renderLoading({ refreshing = false } = {}) {
if (search && !filesLoaded) search.disabled = true;
if (summary) {
summary.innerHTML = `<span class="ui-spinner ui-spinner--small" aria-hidden="true"></span><span>${escapeHtml(t(refreshing ? "files.refreshing" : "files.loading", {}, refreshing ? "Refreshing…" : "Loading files…"))}</span>`;
}
if (refreshing && filesLoaded) return;
list.innerHTML = Array.from({ length: 3 }, () => `
<div class="file-row file-row--skeleton" aria-hidden="true">
<div class="file-row-main"><span class="file-skeleton file-skeleton--badge"></span><div class="file-copy"><span class="file-skeleton file-skeleton--name"></span><span class="file-skeleton file-skeleton--meta"></span></div></div>
<span class="file-skeleton file-skeleton--action"></span>
</div>`).join("");
}
function renderLoadError(error) {
if (search && !filesLoaded) search.disabled = true;
if (summary) summary.textContent = t("files.loadFailed", {}, "Could not load files");
if (filesLoaded) return;
list.innerHTML = `<div class="files-state files-state--error"><div class="files-state-mark" aria-hidden="true">!</div><strong>${escapeHtml(t("files.loadFailed", {}, "Could not load files"))}</strong><span>${escapeHtml(error?.message || "")}</span><button class="action-button action-button--secondary compact-button" type="button" data-files-retry>${escapeHtml(t("files.retry", {}, "Try again"))}</button></div>`;
}
async function loadFiles({ open = false } = {}) {
if (open && !dialog.open) dialog.showModal();
const sequence = ++loadSequence;
renderLoading({ refreshing: filesLoaded });
try {
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);
footer.textContent = tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
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-main">
<div class="file-name">${escapeHtml(file.filename)}</div>
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
</div>
<div class="file-actions">${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>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
list.innerHTML = restrictionNotice + fileRows;
onFilesChanged(files);
if (open && !dialog.open) dialog.showModal();
if (sequence !== loadSequence) return;
currentFiles = Array.isArray(files) ? files : [];
filesLoaded = true;
const totalSize = currentFiles.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
renderFiles();
onFilesChanged(currentFiles);
} catch (error) {
if (open) notify("danger", error.message, { title: "Could not load files" });
if (sequence !== loadSequence) return;
renderLoadError(error);
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") });
}
}
@@ -329,7 +421,25 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
footer.addEventListener("click", () => loadFiles({ open: true }));
document.querySelector("#close-files").addEventListener("click", () => dialog.close());
search?.addEventListener("input", renderFiles);
list.addEventListener("click", async event => {
const retryButton = event.target.closest("[data-files-retry]");
if (retryButton) {
void loadFiles();
return;
}
const toggleButton = event.target.closest("[data-toggle-file-actions]");
if (toggleButton) {
const panel = toggleButton.closest(".file-row")?.querySelector(".file-secondary-actions");
if (!panel) return;
const expanded = panel.hidden;
panel.hidden = !expanded;
toggleButton.setAttribute("aria-expanded", String(expanded));
toggleButton.textContent = expanded ? t("files.less", {}, "Less") : t("common.more", {}, "More");
return;
}
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const mode = addButton.dataset.insertMode;