diff --git a/Cargo.lock b/Cargo.lock index cd9da8a..01d9c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.78" +version = "0.2.79" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index a623b0a..bfa54df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.78" +version = "0.2.79" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/lang/en.json b/lang/en.json index 7581e61..e340606 100644 --- a/lang/en.json +++ b/lang/en.json @@ -360,6 +360,8 @@ "editor.emoji": "😀 Emoji", "editor.emojiCategories": "Emoji categories", "editor.emojiEmpty": "No emoji found.", + "editor.emojiLoading": "Loading emoji…", + "editor.emojiLoadFailed": "Could not load emoji. Close and reopen the picker to retry.", "editor.emojiLabel": "Emoji", "editor.emojiSearch": "Search emoji", "editor.emojiSearchPlaceholder": "Search emoji…", @@ -600,6 +602,12 @@ "files.download": "Download {label}", "files.downloadCode": "Download code", "files.empty": "No files uploaded.", + "files.filter": "Filter files", + "files.filterPlaceholder": "Filter files…", + "files.loading": "Loading files…", + "files.refreshing": "Refreshing…", + "files.noMatches": "No files match this filter.", + "files.retry": "Try again", "files.generatedCode": "Generated file code", "files.imageAlignment": "Image alignment", "files.imageHeight": "Image height", @@ -957,6 +965,12 @@ "error.document.publishedNotFound": "Published page not found · RustPad", "error.document.pageNotFound": "Page not found · RustPad", "error.document.methodNotAllowed": "Method not allowed · RustPad", - "error.document.serverError": "Server error · RustPad" + "error.document.serverError": "Server error · RustPad", + "files.emptyHelp": "Upload a file to keep it with this note and insert it into the document.", + "files.filteredSummary": "{shown} of {count} files · {size}", + "files.less": "Less", + "files.noMatchesHelp": "Try a different file name or type.", + "files.link": "Link", + "files.markdown": "Markdown" } } diff --git a/lang/pl.json b/lang/pl.json index 70d63ac..9554d12 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -360,6 +360,8 @@ "editor.emoji": "😀 Emoji", "editor.emojiCategories": "Kategorie emoji", "editor.emojiEmpty": "Nie znaleziono emoji.", + "editor.emojiLoading": "Ładowanie emoji…", + "editor.emojiLoadFailed": "Nie udało się wczytać emoji. Zamknij i otwórz ponownie wybór emoji, aby spróbować jeszcze raz.", "editor.emojiLabel": "Emoji", "editor.emojiSearch": "Szukaj emoji", "editor.emojiSearchPlaceholder": "Szukaj emoji…", @@ -600,6 +602,12 @@ "files.download": "Pobierz {label}", "files.downloadCode": "Kod pobierania", "files.empty": "Nie przesłano żadnych plików.", + "files.filter": "Filtruj pliki", + "files.filterPlaceholder": "Filtruj pliki…", + "files.loading": "Ładowanie plików…", + "files.refreshing": "Odświeżanie…", + "files.noMatches": "Żaden plik nie pasuje do filtra.", + "files.retry": "Spróbuj ponownie", "files.generatedCode": "Wygenerowany kod pliku", "files.imageAlignment": "Wyrównanie obrazu", "files.imageHeight": "Wysokość obrazu", @@ -957,6 +965,12 @@ "error.document.publishedNotFound": "Nie znaleziono opublikowanej strony · RustPad", "error.document.pageNotFound": "Nie znaleziono strony · RustPad", "error.document.methodNotAllowed": "Metoda niedozwolona · RustPad", - "error.document.serverError": "Błąd serwera · RustPad" + "error.document.serverError": "Błąd serwera · RustPad", + "files.emptyHelp": "Prześlij plik, aby zachować go przy tej notatce i wstawiać do dokumentu.", + "files.filteredSummary": "{shown} z {count} plików · {size}", + "files.less": "Mniej", + "files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.", + "files.link": "Link", + "files.markdown": "Markdown" } } diff --git a/static/css/styles.css b/static/css/styles.css index 385e519..d4a4bca 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2810,20 +2810,16 @@ dialog::backdrop { } .files-dialog { + width: min(940px, calc(100% - 28px)); overflow: hidden; } .files-panel { - grid-template-rows: auto minmax(0, 1fr); - - max-height: calc(100vh - 28px); -} - -.files-list { - - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; + height: min(650px, calc(100dvh - 28px)); + max-height: calc(100dvh - 28px); + gap: 14px; } .files-head { @@ -2838,10 +2834,17 @@ dialog::backdrop { margin: 0; } +.files-head h2 { + font-size: 1.08rem; + letter-spacing: -.01em; +} + .files-head p { + max-width: 620px; margin-top: 5px; color: var(--muted); font-size: .8rem; + line-height: 1.5; } .files-head-actions { @@ -2856,110 +2859,163 @@ dialog::backdrop { width: auto; } +.files-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 320px); + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 11px; + background: color-mix(in srgb, var(--surface-2) 72%, transparent); +} + +.files-dialog-summary { + display: flex; + align-items: center; + min-width: 0; + gap: 8px; + color: var(--text-secondary); + font-size: .78rem; + font-weight: 650; +} + +.files-search { + width: 100%; + min-height: 38px; + margin: 0; + border-radius: 8px; +} + .files-list { display: grid; - gap: 9px; - overflow: auto; - min-height: 80px; + align-content: start; + gap: 10px; + min-height: 100px; + overflow-y: auto; + overscroll-behavior: contain; + padding: 1px 3px 2px 1px; + scrollbar-gutter: stable; } .file-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - padding: 12px; + gap: 12px 16px; + align-items: center; + padding: 13px 14px; border: 1px solid var(--border); - border-radius: 9px; + border-radius: 12px; background: var(--control-bg); + transition: border-color .14s ease, background .14s ease, transform .14s ease; +} + +.file-row:not(.file-row--skeleton):hover { + border-color: var(--border-strong); + background: color-mix(in srgb, var(--surface-2) 78%, var(--control-bg)); +} + +.file-row-main { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 12px; + align-items: center; + min-width: 0; +} + +.file-type-badge { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--surface-2); + color: var(--accent-text-strong); + font-size: .64rem; + font-weight: 800; + letter-spacing: .045em; + text-transform: uppercase; +} + +.file-copy { + min-width: 0; } .file-name { overflow: hidden; + color: var(--text); + font-size: .86rem; + font-weight: 700; + line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; - font-weight: 650; } .file-meta { - margin-top: 4px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px 8px; + margin-top: 5px; color: var(--muted-2); - font-size: .72rem; + font-size: .7rem; +} + +.file-meta > span:not(.file-flag) + span:not(.file-flag)::before { + content: "·"; + margin-right: 8px; + color: var(--border-strong); +} + +.file-flag { + margin-left: 1px; } .file-actions { display: flex; - - justify-content: flex-end; - gap: 6px; -} - -.file-actions button { - - border: 1px solid var(--border); - border-radius: 6px; - background: var(--surface-2); - color: var(--text); - padding: 0 8px; - font-size: .72rem; -} - -@media (max-width: 620px) { - .files-head { - gap: 10px; - } - - .files-head-actions { - gap: 6px; - } - - .files-head-actions .action-button { - padding-inline: 9px; - } - - .file-row { - grid-template-columns: 1fr; - } - - .file-actions { - justify-content: flex-start; - } -} - -.files-panel { - min-height: 0; - height: min(590px, calc(100vh - 28px)); -} - -.files-list { - align-content: start; -} - -.file-row { - align-items: start; -} - -.file-row-main { - min-width: 0; -} - -.file-actions { align-items: center; - align-self: start; - flex-wrap: nowrap; + justify-content: flex-end; + gap: 7px; } -.file-actions button { +.file-actions button, +.file-secondary-actions button { min-height: 34px; height: 34px; white-space: nowrap; } +.file-primary-action { + min-width: 102px; +} + +.file-more-button[aria-expanded="true"] { + border-color: var(--border-strong); + background: var(--surface-2); +} + +.file-secondary-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; + padding-top: 11px; + border-top: 1px solid var(--border); +} + +.file-secondary-actions[hidden] { + display: none; +} + .file-code { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: stretch; + padding-top: 2px; } .file-code[hidden] { @@ -2968,10 +3024,11 @@ dialog::backdrop { .file-code textarea { width: 100%; - min-height: 76px; + min-height: 72px; max-height: 150px; resize: vertical; box-sizing: border-box; + border-radius: 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -2984,19 +3041,175 @@ dialog::backdrop { margin: 0; padding: 10px 12px; border: 1px solid var(--border-strong); - border-radius: 9px; + border-radius: 10px; background: var(--surface-2); color: var(--text-secondary); - font-size: .8rem; + font-size: .78rem; line-height: 1.45; } -@media (max-width: 760px) { +.files-state { + display: grid; + place-items: center; + align-content: center; + min-height: 250px; + padding: 28px; + color: var(--muted); + text-align: center; +} + +.files-state strong { + margin-top: 10px; + color: var(--text); + font-size: .88rem; +} + +.files-state span { + max-width: 420px; + margin-top: 5px; + font-size: .76rem; + line-height: 1.5; +} + +.files-state .action-button { + margin-top: 14px; +} + +.files-state-mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border: 1px solid var(--border-strong); + border-radius: 13px; + background: var(--surface-2); + color: var(--accent-text-strong); + font-size: 1.25rem; + font-weight: 500; +} + +.files-state--error .files-state-mark { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + color: var(--danger); +} + +.file-row--skeleton { + pointer-events: none; +} + +.file-skeleton { + display: block; + border-radius: 7px; + background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)); + background-size: 220% 100%; + animation: file-skeleton-pulse 1.25s ease-in-out infinite; +} + +.file-skeleton--badge { + width: 44px; + height: 44px; + border-radius: 10px; +} + +.file-skeleton--name { + width: min(260px, 80%); + height: 12px; +} + +.file-skeleton--meta { + width: min(360px, 95%); + height: 9px; + margin-top: 9px; +} + +.file-skeleton--action { + width: 132px; + height: 34px; +} + +@keyframes file-skeleton-pulse { + from { background-position: 100% 0; } + to { background-position: -100% 0; } +} + +.ui-spinner { + display: inline-block; + width: 22px; + height: 22px; + box-sizing: border-box; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: ui-spinner-rotate .7s linear infinite; +} + +.ui-spinner--small { + width: 14px; + height: 14px; + border-width: 2px; +} + +@keyframes ui-spinner-rotate { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .file-row, + .file-skeleton, + .ui-spinner { + transition: none; + animation: none; + } +} + +@media (max-width: 720px) { + .files-panel { + height: min(720px, calc(100dvh - 18px)); + padding: 14px; + } + + .files-head { + gap: 10px; + } + + .files-head p { + display: none; + } + + .files-head-actions { + gap: 6px; + } + + .files-head-actions .action-button { + padding-inline: 9px; + } + + .files-toolbar { + grid-template-columns: 1fr; + gap: 8px; + } + + .files-search { + min-height: 40px; + } + + .file-row { + grid-template-columns: 1fr; + gap: 11px; + } + .file-actions { - flex-wrap: wrap; justify-content: flex-start; } + .file-primary-action { + flex: 1 1 auto; + } + + .file-secondary-actions { + padding-top: 10px; + } + .file-code { grid-template-columns: 1fr; } @@ -5532,6 +5745,31 @@ dialog::backdrop { box-shadow: 0 14px 40px var(--shadow-45); } +.emoji-loading { + display: grid; + place-items: center; + align-content: center; + gap: 10px; + min-height: 210px; + padding: 22px; + color: var(--muted); + text-align: center; + font-size: .78rem; + line-height: 1.45; +} + +.emoji-loading[hidden] { + display: none; +} + +.emoji-loading.is-error { + color: var(--text-secondary); +} + +.emoji-picker-content[hidden] { + display: none; +} + .emoji-search { width: 100%; min-height: 36px; diff --git a/static/editor.html b/static/editor.html index 31a6ac9..3144c5f 100644 --- a/static/editor.html +++ b/static/editor.html @@ -94,11 +94,17 @@
😀 Emoji
- -
-
- +
+ + Loading emoji… +
+
@@ -331,7 +337,12 @@ -
+
+
Loading files…
+ +
+
diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 0987191..8aa1034 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -2102,13 +2102,44 @@ export function startNoteEditor(adapter) { grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty"), }; + const emojiLoading = document.querySelector("#emoji-loading"); + const emojiLoadingText = document.querySelector("#emoji-loading-text"); + const emojiContent = document.querySelector("#emoji-content"); let emojiPickerLoad = null; + let emojiPickerBound = false; + + const setEmojiPickerState = state => { + const failed = state === "error"; + const ready = state === "ready"; + emojiPickerArgs.details?.setAttribute("aria-busy", String(state === "loading")); + if (emojiLoading) { + emojiLoading.hidden = ready; + emojiLoading.classList.toggle("is-error", failed); + const spinner = emojiLoading.querySelector(".ui-spinner"); + if (spinner) spinner.hidden = failed; + } + if (emojiLoadingText) { + emojiLoadingText.textContent = failed + ? t("editor.emojiLoadFailed", {}, "Could not load emoji. Close and reopen the picker to retry.") + : t("editor.emojiLoading", {}, "Loading emoji…"); + } + if (emojiContent) emojiContent.hidden = !ready; + }; + const loadEmojiPicker = async () => { + if (emojiPickerBound) { + setEmojiPickerState("ready"); + return; + } if (emojiPickerLoad) return emojiPickerLoad; + setEmojiPickerState("loading"); emojiPickerLoad = import("@rustpad/emoji-picker").then(({ bindEmojiPicker }) => { bindEmojiPicker(emojiPickerArgs); + emojiPickerBound = true; + setEmojiPickerState("ready"); }).catch(error => { emojiPickerLoad = null; + setEmojiPickerState("error"); throw error; }); return emojiPickerLoad; diff --git a/static/js/note-files.js b/static/js/note-files.js index ba4ccd5..237355c 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -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) - ? ` - ` - : ``; - const codeButtons = isVideo(file.mime_type) - ? ` - - ` - : ` - - `; - const deleteButton = canDelete() - ? `` + const primaryInsert = isVideo(file.mime_type) + ? `` + : ``; + const secondaryInsert = isVideo(file.mime_type) + ? `` : ""; - return `${codeButtons}${insertButtons}${deleteButton}`; + const codeButtons = isVideo(file.mime_type) + ? ` + + ` + : ` + + `; + const deleteButton = canDelete() + ? `` + : ""; + return { + primary: `${primaryInsert}`, + 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 ` +
+
+ +
+
${escapeHtml(file.filename)}
+
+ ${formatBytes(file.size_bytes)} + ${escapeHtml(file.mime_type || "file")} + ${created ? `${escapeHtml(created)}` : ""} + ${escapeHtml(status)} +
+
+
+
${actions.primary}
+ + +
`; + } + + function renderFiles() { + if (!filesLoaded) return; + if (search) search.disabled = false; + const query = String(search?.value || "").trim().toLocaleLowerCase(); + const restrictionMessage = getDeleteRestrictionMessage(); + const restrictionNotice = restrictionMessage + ? `

${escapeHtml(restrictionMessage)}

` + : ""; + 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}
${escapeHtml(t("files.empty", {}, "No files uploaded."))}${escapeHtml(t("files.emptyHelp", {}, "Upload a file to keep it with this note and insert it into the document."))}
`; + return; + } + if (!visible.length) { + list.innerHTML = `${restrictionNotice}
${escapeHtml(t("files.noMatches", {}, "No files match this filter."))}${escapeHtml(t("files.noMatchesHelp", {}, "Try a different file name or type."))}
`; + return; + } + list.innerHTML = restrictionNotice + visible.map(fileRow).join(""); + } + + function renderLoading({ refreshing = false } = {}) { + if (search && !filesLoaded) search.disabled = true; + if (summary) { + summary.innerHTML = `${escapeHtml(t(refreshing ? "files.refreshing" : "files.loading", {}, refreshing ? "Refreshing…" : "Loading files…"))}`; + } + if (refreshing && filesLoaded) return; + list.innerHTML = Array.from({ length: 3 }, () => ` + `).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 = `
${escapeHtml(t("files.loadFailed", {}, "Could not load files"))}${escapeHtml(error?.message || "")}
`; } 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 - ? `

${escapeHtml(restrictionMessage)}

` - : ""; - const fileRows = files.length ? files.map(file => ` -
-
-
${escapeHtml(file.filename)}
-
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · ${file.is_attached ? "in note" : "removed from content"}
-
-
${fileActionButtons(file)}
- -
`).join("") : '

No files uploaded.

'; - 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;