v0.2.79
This commit is contained in:
+144
-34
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user