favourited notes
This commit is contained in:
@@ -594,6 +594,56 @@ textarea:focus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-heading-group {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document-heading-group .document-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorite-toggle {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
transition: color .15s ease, text-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
|
||||
.favorite-toggle:not([aria-pressed="true"]):hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.favorite-toggle:focus-visible {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.favorite-toggle[aria-pressed="true"] {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.favorite-toggle:disabled {
|
||||
opacity: .6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.document-heading--copy {
|
||||
border-radius: 6px;
|
||||
cursor: copy;
|
||||
@@ -2633,6 +2683,55 @@ dialog::backdrop {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.note-card-wrap--favorite-enabled .note-card {
|
||||
padding-right: 58px;
|
||||
}
|
||||
|
||||
.note-card-favorite {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.note-favorite-button {
|
||||
display: inline-grid;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
transition: color .15s ease, text-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
|
||||
.note-favorite-button:not([aria-pressed="true"]):hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.note-favorite-button:focus-visible {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.note-favorite-button[aria-pressed="true"] {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.note-favorite-button:disabled {
|
||||
opacity: .6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.protect-badge,
|
||||
.file-flag {
|
||||
display: inline-flex;
|
||||
@@ -3133,6 +3232,22 @@ dialog::backdrop {
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.notes-table-favorite {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
padding-right: 8px !important;
|
||||
padding-left: 8px !important;
|
||||
text-align: center !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-favorite-button--inline {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.notes-table-actions {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
@@ -4148,6 +4263,79 @@ dialog::backdrop {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.resources-category {
|
||||
margin-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resources-category--favorites {
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.resources-category__header h3,
|
||||
.resources-category__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resources-category__header h3 {
|
||||
font-size: .92rem;
|
||||
}
|
||||
|
||||
.resources-category__header p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.resources-category .resources-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.resource-row.resource-row--favorite {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.resource-row--favorite .resource-copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.resource-favorite-remove {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: color .15s ease, text-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
|
||||
.resource-favorite-remove:hover,
|
||||
.resource-favorite-remove:focus-visible {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.resource-favorite-remove:disabled {
|
||||
opacity: .6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.resource-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -5940,6 +6128,11 @@ dialog::backdrop {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pad-page .document-heading-group {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pad-page .header-user-control {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
+8
-4
@@ -20,10 +20,14 @@
|
||||
<nav class="resource-brand__trail" aria-label="Current location">__RESOURCE_BREADCRUMB__</nav>
|
||||
</div>
|
||||
<span class="header-divider"></span>
|
||||
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
|
||||
title="Copy this link" aria-label="Copy this link">
|
||||
<h1 id="document-title" __DOCUMENT_TITLE_I18N__>__DOCUMENT_TITLE__</h1>
|
||||
<p id="document-url" class="document-url"></p>
|
||||
<div class="document-heading-group">
|
||||
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
|
||||
title="Copy this link" aria-label="Copy this link">
|
||||
<h1 id="document-title" __DOCUMENT_TITLE_I18N__>__DOCUMENT_TITLE__</h1>
|
||||
<p id="document-url" class="document-url"></p>
|
||||
</div>
|
||||
<button id="favorite-toggle" class="favorite-toggle" type="button" aria-pressed="false"
|
||||
title="Add to favorites" aria-label="Add to favorites" hidden><span aria-hidden="true">☆</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
|
||||
|
||||
+14
-2
@@ -158,8 +158,20 @@
|
||||
<option value="100">100</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div id="resources-list" class="resources-list"></div>
|
||||
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
|
||||
<section class="resources-category resources-category--favorites">
|
||||
<header class="resources-category__header">
|
||||
<h3>Favorites</h3>
|
||||
<p>Favorite notes are shown only while your current account, share link, or password access can still open them. If access is lost, they are hidden and return automatically when access is restored.</p>
|
||||
</header>
|
||||
<div id="favorites-list" class="resources-list resources-list--favorites"></div>
|
||||
</section>
|
||||
<section class="resources-category">
|
||||
<header class="resources-category__header">
|
||||
<h3>Owned and shared</h3>
|
||||
</header>
|
||||
<div id="resources-list" class="resources-list"></div>
|
||||
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
|
||||
</section>
|
||||
<p id="resources-error" class="form-message error" role="alert"></p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
+39
-3
@@ -109,6 +109,7 @@ 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 favoritesList = document.querySelector("#favorites-list");
|
||||
const resourcesError = document.querySelector("#resources-error");
|
||||
const resourcesSearch = document.querySelector("#resources-search");
|
||||
const resourcesPerPage = document.querySelector("#resources-per-page");
|
||||
@@ -174,11 +175,46 @@ document.addEventListener("click", event => {
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") closeResourcePasswordMenus();
|
||||
});
|
||||
|
||||
function renderFavorites(items) {
|
||||
if (!favoritesList) return;
|
||||
favoritesList.innerHTML = items.length ? "" : `<p>${escapeHtml(t("favorites.empty", {}, "No favorites yet."))}</p>`;
|
||||
for (const item of items) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "resource-row resource-row--favorite";
|
||||
const workspace = item.workspace_title
|
||||
? ` · ${t("common.workspace", {}, "Workspace")}: ${item.workspace_title}`
|
||||
: "";
|
||||
row.innerHTML = `<div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a></div><small>${escapeHtml(t("common.note", {}, "Note"))}${escapeHtml(workspace)}</small></div><button class="resource-favorite-remove" type="button" data-unfavorite title="${escapeHtml(t("favorites.remove", {}, "Remove from favorites"))}" aria-label="${escapeHtml(t("favorites.remove", {}, "Remove from favorites"))}">★</button>`;
|
||||
row.querySelector("[data-unfavorite]")?.addEventListener("click", async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const target = { kind: item.kind, slug: item.slug };
|
||||
if (item.workspace_slug) target.workspace_slug = item.workspace_slug;
|
||||
await api("/api/auth/favorites", { method: "DELETE", headers: authHeaders(), body: JSON.stringify(target) });
|
||||
toast.success(t("favorites.removed", {}, "Removed from favorites."), { title: t("favorites.title", {}, "Favorites") });
|
||||
await loadResources();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
resourcesError.textContent = error.message;
|
||||
toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });
|
||||
}
|
||||
});
|
||||
favoritesList.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResources() {
|
||||
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
|
||||
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>"; if (favoritesList) favoritesList.innerHTML = "<p>Loading…</p>";
|
||||
try {
|
||||
const params = new URLSearchParams({ q: resourcesSearch.value.trim(), page: String(resourcesPage), per_page: resourcesPerPage.value });
|
||||
const data = await api(`/api/auth/resources?${params}`, { headers: authHeaders() });
|
||||
const favoriteParams = new URLSearchParams({ q: resourcesSearch.value.trim() });
|
||||
const [data, favorites] = await Promise.all([
|
||||
api(`/api/auth/resources?${params}`, { headers: authHeaders() }),
|
||||
api(`/api/auth/favorites?${favoriteParams}`, { headers: authHeaders() }),
|
||||
]);
|
||||
renderFavorites(favorites.items || []);
|
||||
resourcesPage = data.pagination.page;
|
||||
const items = data.items.map(item => ({ ...item, url: item.kind === "workspace" ? `/w/${item.slug}` : `/p/${item.slug}` }));
|
||||
resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>";
|
||||
@@ -395,7 +431,7 @@ async function loadResources() {
|
||||
resourcesList.append(row);
|
||||
}
|
||||
renderResourcesPagination(data.pagination);
|
||||
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); }
|
||||
} catch (e) { resourcesList.innerHTML = ""; if (favoritesList) favoritesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); }
|
||||
}
|
||||
|
||||
resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); });
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createPadAdapter() {
|
||||
|
||||
return {
|
||||
access: { kind: "pad", key: slug },
|
||||
favorite: { kind: "pad", slug },
|
||||
passwordScope: "note",
|
||||
addressSelector: "#document-url",
|
||||
title: info => `${info.title} · RustPad`,
|
||||
@@ -68,6 +69,7 @@ export function createWorkspaceNoteAdapter() {
|
||||
|
||||
return {
|
||||
access: { kind: "workspace", key: workspaceSlug },
|
||||
favorite: { kind: "note", slug: noteSlug, workspace_slug: workspaceSlug },
|
||||
passwordScope: "workspace",
|
||||
addressSelector: "#document-url",
|
||||
title: info => `${info.title} · ${info.workspace_title}`,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
||||
import { api } from "@rustpad/api";
|
||||
installGlobalDiagnostics();
|
||||
|
||||
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
|
||||
@@ -36,7 +37,7 @@ export function startNoteEditor(adapter) {
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
||||
const saveState = document.querySelector("#save-state");
|
||||
const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle");
|
||||
editor.readOnly = true;
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||
@@ -49,7 +50,7 @@ export function startNoteEditor(adapter) {
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false, accountSession = null;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
let pendingPreviewViewport = null;
|
||||
const editHistory = {
|
||||
@@ -225,6 +226,41 @@ export function startNoteEditor(adapter) {
|
||||
: {};
|
||||
}
|
||||
function accountHeaders() { return {}; }
|
||||
function favoriteParams() {
|
||||
const target = adapter.favorite;
|
||||
if (!target) return "";
|
||||
const params = new URLSearchParams({ kind: target.kind, slug: target.slug });
|
||||
if (target.workspace_slug) params.set("workspace_slug", target.workspace_slug);
|
||||
return params.toString();
|
||||
}
|
||||
function renderFavoriteButton(active) {
|
||||
if (!favoriteToggle) return;
|
||||
const favorite = Boolean(active);
|
||||
favoriteToggle.hidden = !accountSession || !adapter.favorite;
|
||||
favoriteToggle.setAttribute("aria-pressed", String(favorite));
|
||||
favoriteToggle.querySelector("span").textContent = favorite ? "★" : "☆";
|
||||
const label = favorite
|
||||
? t("favorites.remove", {}, "Remove from favorites")
|
||||
: t("favorites.add", {}, "Add to favorites");
|
||||
favoriteToggle.title = label;
|
||||
favoriteToggle.setAttribute("aria-label", label);
|
||||
}
|
||||
async function syncFavoriteButton() {
|
||||
if (!favoriteToggle || !adapter.favorite || !accountSession) {
|
||||
renderFavoriteButton(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await api(`/api/auth/favorites/status?${favoriteParams()}`);
|
||||
renderFavoriteButton(Boolean(status.favorite));
|
||||
} catch (error) {
|
||||
if (error.status === 401 || error.status === 403 || error.status === 404) {
|
||||
favoriteToggle.hidden = true;
|
||||
return;
|
||||
}
|
||||
renderFavoriteButton(false);
|
||||
}
|
||||
}
|
||||
async function loadNoteInfo() {
|
||||
info = await adapter.loadInfo(sessionHeaders());
|
||||
const documentTitle = document.querySelector("#document-title");
|
||||
@@ -1574,7 +1610,7 @@ export function startNoteEditor(adapter) {
|
||||
});
|
||||
socket.connect();
|
||||
}
|
||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||
async function showSystemNotFound() {
|
||||
try {
|
||||
@@ -1594,8 +1630,8 @@ export function startNoteEditor(adapter) {
|
||||
async function initialize() {
|
||||
applyUi();
|
||||
try {
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
accountSession = await validateCurrentSession();
|
||||
nickname = accountSession?.nickname || getNickname();
|
||||
if (!nickname) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
return;
|
||||
@@ -1608,6 +1644,7 @@ export function startNoteEditor(adapter) {
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
await syncFavoriteButton();
|
||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
} catch (e) {
|
||||
@@ -1943,6 +1980,37 @@ export function startNoteEditor(adapter) {
|
||||
event.preventDefault();
|
||||
copyCurrentLink();
|
||||
});
|
||||
favoriteToggle?.addEventListener("click", async () => {
|
||||
if (!accountSession || !adapter.favorite) return;
|
||||
const currentlyFavorite = favoriteToggle.getAttribute("aria-pressed") === "true";
|
||||
favoriteToggle.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/auth/favorites", {
|
||||
method: currentlyFavorite ? "DELETE" : "PUT",
|
||||
body: JSON.stringify(adapter.favorite),
|
||||
});
|
||||
renderFavoriteButton(Boolean(result.favorite));
|
||||
toast.success(
|
||||
result.favorite
|
||||
? t("favorites.added", {}, "Added to favorites.")
|
||||
: t("favorites.removed", {}, "Removed from favorites."),
|
||||
{ title: t("favorites.title", {}, "Favorites") },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });
|
||||
await syncFavoriteButton();
|
||||
} finally {
|
||||
favoriteToggle.disabled = false;
|
||||
}
|
||||
});
|
||||
window.addEventListener("rustpad:session-change", event => {
|
||||
accountSession = event.detail?.session || null;
|
||||
syncFavoriteButton();
|
||||
});
|
||||
window.addEventListener("rustpad:session-expired", () => {
|
||||
accountSession = null;
|
||||
renderFavoriteButton(false);
|
||||
});
|
||||
|
||||
let pendingPreviewFormatRange = null;
|
||||
document.querySelectorAll("[data-format]").forEach(button => {
|
||||
@@ -2161,6 +2229,7 @@ export function startNoteEditor(adapter) {
|
||||
password = "";
|
||||
setPagePasswordInput.value = "";
|
||||
await loadNoteInfo();
|
||||
await syncFavoriteButton();
|
||||
resourceUnlocked = false;
|
||||
socket?.stop();
|
||||
loadFiles();
|
||||
@@ -2258,7 +2327,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#open-password")?.focus();
|
||||
}
|
||||
});
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); await syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
|
||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
|
||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
|
||||
|
||||
+74
-5
@@ -12,12 +12,12 @@ installGlobalDiagnostics();
|
||||
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
|
||||
import { getNickname, getAccessToken, getGuestId, setAccessToken } from "@rustpad/session";
|
||||
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
||||
import { askConfirm } from "@rustpad/modal";
|
||||
import { isResourceAccessError, safeAppUrl } from "@rustpad/security";
|
||||
import { consumeQueuedToast, toast } from "@rustpad/toast";
|
||||
import { formatDateTime, formatNumber } from "@rustpad/i18n";
|
||||
import { formatDateTime, formatNumber, t } from "@rustpad/i18n";
|
||||
|
||||
consumeQueuedToast();
|
||||
|
||||
@@ -27,6 +27,7 @@ let info;
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
let accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
let nickname = getNickname();
|
||||
let accountSession = null;
|
||||
getGuestId();
|
||||
const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`;
|
||||
let workspaceWatchSocket;
|
||||
@@ -177,6 +178,57 @@ function deleteButton(note, inline = false) {
|
||||
: "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>`;
|
||||
}
|
||||
function favoriteButton(note, inline = false) {
|
||||
if (!accountSession) return "";
|
||||
const active = Boolean(note.favorite);
|
||||
const label = active
|
||||
? t("favorites.remove", {}, "Remove from favorites")
|
||||
: t("favorites.add", {}, "Add to favorites");
|
||||
return `<button class="note-favorite-button${inline ? " note-favorite-button--inline" : ""}" type="button" data-favorite-note="${escapeHtml(note.slug)}" aria-pressed="${active}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"><span aria-hidden="true">${active ? "★" : "☆"}</span></button>`;
|
||||
}
|
||||
function updateFavoriteButton(button, active) {
|
||||
const favorite = Boolean(active);
|
||||
const label = favorite
|
||||
? t("favorites.remove", {}, "Remove from favorites")
|
||||
: t("favorites.add", {}, "Add to favorites");
|
||||
button.setAttribute("aria-pressed", String(favorite));
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
const icon = button.querySelector("span");
|
||||
if (icon) icon.textContent = favorite ? "★" : "☆";
|
||||
}
|
||||
async function toggleNoteFavorite(button) {
|
||||
if (!accountSession) return;
|
||||
const note = notesCache.find(item => item.slug === button.dataset.favoriteNote);
|
||||
if (!note) return;
|
||||
const current = Boolean(note.favorite);
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/auth/favorites", {
|
||||
method: current ? "DELETE" : "PUT",
|
||||
body: JSON.stringify({ kind: "note", slug: note.slug, workspace_slug: slug }),
|
||||
});
|
||||
note.favorite = Boolean(result.favorite);
|
||||
document.querySelectorAll(`[data-favorite-note="${CSS.escape(note.slug)}"]`).forEach(target => updateFavoriteButton(target, note.favorite));
|
||||
toast.success(
|
||||
note.favorite
|
||||
? t("favorites.added", {}, "Added to favorites.")
|
||||
: t("favorites.removed", {}, "Removed from favorites."),
|
||||
{ title: t("favorites.title", {}, "Favorites") },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
const session = await validateCurrentSession();
|
||||
if (!session) {
|
||||
accountSession = null;
|
||||
renderNotes();
|
||||
}
|
||||
}
|
||||
toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });
|
||||
} finally {
|
||||
if (button.isConnected) button.disabled = false;
|
||||
}
|
||||
}
|
||||
function renderNotes(notes = notesCache) {
|
||||
notesCache = notes;
|
||||
setNotesView(notesView);
|
||||
@@ -185,8 +237,10 @@ function renderNotes(notes = notesCache) {
|
||||
return;
|
||||
}
|
||||
if (notesView === "table") {
|
||||
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
||||
const favoriteHeader = accountSession ? '<th class="notes-table-favorite"><span class="sr-only">Favorites</span>★</th>' : "";
|
||||
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr>${favoriteHeader}<th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
||||
<tr>
|
||||
${accountSession ? `<td class="notes-table-favorite">${favoriteButton(note, true)}</td>` : ""}
|
||||
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
|
||||
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
||||
<td>${Number(note.participant_count) || 0}</td>
|
||||
@@ -199,11 +253,12 @@ function renderNotes(notes = notesCache) {
|
||||
return;
|
||||
}
|
||||
notesList.innerHTML = notes.map(note => `
|
||||
<article class="note-card-wrap">
|
||||
<article class="note-card-wrap${accountSession ? " note-card-wrap--favorite-enabled" : ""}">
|
||||
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
|
||||
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
||||
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
|
||||
</a>
|
||||
${accountSession ? `<div class="note-card-favorite">${favoriteButton(note)}</div>` : ""}
|
||||
${deleteButton(note)}
|
||||
</article>`).join("");
|
||||
}
|
||||
@@ -323,6 +378,12 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||
} catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); }
|
||||
});
|
||||
notesList.addEventListener("click", async event => {
|
||||
const favorite = event.target.closest("[data-favorite-note]");
|
||||
if (favorite) {
|
||||
event.preventDefault();
|
||||
await toggleNoteFavorite(favorite);
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest("[data-delete-note]");
|
||||
if (!button) return;
|
||||
const title = button.dataset.noteTitle;
|
||||
@@ -350,6 +411,7 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async (
|
||||
});
|
||||
async function startAuthorizedWorkspace() {
|
||||
const session = await validateCurrentSession();
|
||||
accountSession = session;
|
||||
nickname = session?.nickname || getNickname();
|
||||
|
||||
if (!nickname) {
|
||||
@@ -365,8 +427,9 @@ async function startAuthorizedWorkspace() {
|
||||
|
||||
bindIdentityDialog({
|
||||
dialog: identityDialog,
|
||||
onIdentity: async value => {
|
||||
onIdentity: async (value, session) => {
|
||||
nickname = value;
|
||||
accountSession = session;
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
@@ -386,6 +449,12 @@ dialog.addEventListener("cancel", event => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("rustpad:session-change", event => {
|
||||
if (event.detail?.session) return;
|
||||
if (!accountSession) return;
|
||||
accountSession = null;
|
||||
renderNotes();
|
||||
});
|
||||
window.addEventListener("beforeunload", stopWorkspaceWatch);
|
||||
|
||||
setNotesView(notesView);
|
||||
|
||||
Reference in New Issue
Block a user