From 4d91ccf48fff8bdaec840e3d8a6dd0902b94b49c Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 31 Mar 2026 12:32:05 +0300 Subject: [PATCH] feat: polish alpine shell feedback --- TASKS.md | 12 ++--- apps/ui/css/pages.css | 80 +++++++++++++++++++++++++++++ apps/ui/html/agents.html | 3 +- apps/ui/html/api-keys.html | 1 + apps/ui/html/settings.html | 7 +-- apps/ui/html/wizard/index.html | 1 + apps/ui/html/workspace-setup.html | 1 + apps/ui/index.html | 1 + apps/ui/js/agents.js | 8 ++- apps/ui/js/api-keys.js | 16 ++++-- apps/ui/js/auth.js | 83 +++++++++++++++++++++++++++++-- apps/ui/js/catalog.js | 4 +- apps/ui/js/settings.js | 73 ++++++++++++++++++++++++++- apps/ui/js/ui-feedback.js | 70 ++++++++++++++++++++++++++ apps/ui/js/wizard.js | 4 +- apps/ui/js/workspace-setup.js | 32 +++++++++--- 16 files changed, 366 insertions(+), 30 deletions(-) create mode 100644 apps/ui/js/ui-feedback.js diff --git a/TASKS.md b/TASKS.md index d932a94..0470141 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/demo-assets` +### `feat/alpine-polish` Status: in_progress DoD: -- project has an idempotent PostgreSQL demo seed for live UI screens -- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage -- seed is controlled by env and does not live in migrations -- docs explain how to enable and use demo data +- Alpine UI pages have consistent feedback and shell state +- alert()-based errors are removed from primary screens +- header identity reflects live session and current workspace +- settings and workspace flows avoid crude localStorage/logout workarounds ## Next -- `feat/alpine-polish` +- `feat/current-workspace-session-model` ## Backlog diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index 1fb23f3..7da02f0 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -125,6 +125,77 @@ .btn-icon.danger:hover { background: var(--red-bg); color: var(--red); border-color: var(--red-border); } +/* ══════════════════════════════════════════════════ + TOASTS +══════════════════════════════════════════════════ */ +.toast-stack { + position: fixed; + top: 84px; + right: 20px; + z-index: 1200; + width: min(360px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(15, 23, 34, 0.96); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + pointer-events: auto; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.toast-card.closing { + opacity: 0; + transform: translateY(-6px); +} + +.toast-info { border-color: rgba(56, 189, 248, 0.28); } +.toast-success { border-color: rgba(63, 185, 80, 0.32); } +.toast-error { border-color: rgba(248, 81, 73, 0.34); } + +.toast-copy { + min-width: 0; + flex: 1; +} + +.toast-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-primary); + margin-bottom: 3px; +} + +.toast-message { + font-size: 12.5px; + line-height: 1.5; + color: var(--text-secondary); +} + +.toast-close { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; +} + +.toast-close:hover { color: var(--text-primary); } + /* ══════════════════════════════════════════════════ SECTION CARD ══════════════════════════════════════════════════ */ @@ -1456,3 +1527,12 @@ flex-shrink: 0; opacity: 0.7; } + +@media (max-width: 900px) { + .toast-stack { + top: 72px; + right: 16px; + left: 16px; + width: auto; + } +} diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 00adaa0..f368f16 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -15,6 +15,7 @@ + @@ -292,7 +293,7 @@
- MCP endpoint: + MCP endpoint:
diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index edf3069..07c30fd 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -26,6 +26,7 @@ + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index 0b83fd3..118c960 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -13,6 +13,7 @@ + @@ -542,21 +543,21 @@
Rename workspace
Changes the workspace slug used in all API endpoints. Existing API keys will continue to work. All existing tool call references will break.
- +
Export all data
Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON. May take a few minutes.
- +
Delete workspace
Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.
- +
diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index fc9c96f..a67e11f 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -13,6 +13,7 @@ + diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 070d735..dc53806 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -14,6 +14,7 @@ + diff --git a/apps/ui/index.html b/apps/ui/index.html index f50b520..795152b 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -13,6 +13,7 @@ + diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js index 81588e2..999cdea 100644 --- a/apps/ui/js/agents.js +++ b/apps/ui/js/agents.js @@ -282,7 +282,9 @@ document.addEventListener('alpine:init', function() { await this.reload(); this.closeDrawer(); } catch (error) { - alert(error.message || 'Failed to save agent'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to save agent', 'Agent update failed'); + } this.saving = false; } }, @@ -294,7 +296,9 @@ document.addEventListener('alpine:init', function() { await window.CrankApi.deleteAgent(this.workspaceId, id); await this.reload(); } catch (error) { - alert(error.message || 'Failed to delete agent'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to delete agent', 'Delete failed'); + } } }, diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js index b1d3b9d..3ce574c 100644 --- a/apps/ui/js/api-keys.js +++ b/apps/ui/js/api-keys.js @@ -72,7 +72,9 @@ async function revokeKey(id) { await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id); await loadKeys(); } catch (error) { - alert(error.message || 'Failed to revoke key'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to revoke key', 'Revoke failed'); + } } } @@ -83,7 +85,9 @@ async function deleteKey(id) { await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id); await loadKeys(); } catch (error) { - alert(error.message || 'Failed to delete key'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to delete key', 'Delete failed'); + } } } @@ -231,7 +235,9 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun return; } if (!selectedScopes.size) { - alert('Select at least one scope.'); + if (window.CrankUi) { + window.CrankUi.info('Select at least one scope before creating a key.', 'Missing scope'); + } return; } @@ -245,7 +251,9 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun document.getElementById('modal-footer-done').style.display = ''; renderTable(); } catch (error) { - alert(error.message || 'Failed to create key'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to create key', 'Key creation failed'); + } } }); diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js index bde39fd..76c8a21 100644 --- a/apps/ui/js/auth.js +++ b/apps/ui/js/auth.js @@ -3,6 +3,20 @@ var sessionCache = null; var sessionPromise = null; + function currentWorkspaceLabel() { + if (window.getCurrentWorkspace) { + var workspace = window.getCurrentWorkspace(); + if (workspace && workspace.name) { + return workspace.name; + } + } + try { + return localStorage.getItem('crank_workspace_slug') || 'workspace'; + } catch (_error) { + return 'workspace'; + } + } + function isLoginPage() { return /\/html\/login\.html$/.test(window.location.pathname); } @@ -21,12 +35,25 @@ try { localStorage.removeItem(STORAGE_KEY); } catch (_error) {} + renderShellIdentity(null); } function primaryMembership(session) { - return session && session.memberships && session.memberships.length - ? session.memberships[0] - : null; + if (!(session && session.memberships && session.memberships.length)) { + return null; + } + if (window.getCurrentWorkspace) { + var currentWorkspace = window.getCurrentWorkspace(); + if (currentWorkspace) { + var matched = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === currentWorkspace.id; + }); + if (matched) { + return matched; + } + } + } + return session.memberships[0]; } function initials(displayName, email) { @@ -56,9 +83,47 @@ } catch (_error) {} } + function mirroredUser() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (_error) { + return null; + } + } + + function renderShellIdentity(session) { + var membership = primaryMembership(session); + var fallbackUser = mirroredUser(); + var displayName = session && session.user + ? (session.user.display_name || session.user.email || 'Crank') + : (fallbackUser && fallbackUser.name) || 'Crank'; + var email = session && session.user + ? session.user.email + : fallbackUser && fallbackUser.email; + var role = membership + ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) + : (fallbackUser && fallbackUser.role) || 'Viewer'; + var workspace = membership && membership.workspace + ? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id) + : currentWorkspaceLabel(); + var avatarValue = initials(displayName, email); + + document.querySelectorAll('.nav-avatar').forEach(function(element) { + element.textContent = avatarValue; + }); + document.querySelectorAll('.user-dropdown-name').forEach(function(element) { + element.textContent = displayName; + }); + document.querySelectorAll('.user-dropdown-role').forEach(function(element) { + element.textContent = role + ' · ' + workspace; + }); + } + function replaceSession(session) { sessionCache = session; persistUserMirror(session); + renderShellIdentity(session); + window.dispatchEvent(new CustomEvent('crank:sessionchange', { detail: session })); return session; } @@ -144,4 +209,16 @@ logout: logout, handleUnauthorized: handleUnauthorized, }; + + window.addEventListener('crank:workspacechange', function() { + renderShellIdentity(sessionCache); + }); + + window.addEventListener('crank:sessionchange', function(event) { + renderShellIdentity(event.detail || sessionCache); + }); + + document.addEventListener('DOMContentLoaded', function() { + renderShellIdentity(sessionCache); + }); }()); diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js index c3bbf16..112fae2 100644 --- a/apps/ui/js/catalog.js +++ b/apps/ui/js/catalog.js @@ -369,7 +369,9 @@ document.addEventListener('alpine:init', function() { }).filter(Boolean))).sort(); this.stats = computeStats(this.operations); } catch (error) { - alert(error.message || 'Failed to delete operation'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to delete operation', 'Delete failed'); + } } }, diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index c4262b4..6f8add3 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -1,5 +1,20 @@ var settingsSession = null; +function settingsWorkspaceId() { + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + return workspace ? workspace.id : null; +} + +function downloadSettingsJson(fileName, value) { + var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + function initials(displayName, email) { var source = displayName || email || 'Crank'; return source @@ -255,7 +270,9 @@ async function loadWorkspaceSettings() { saveButton.textContent = 'Saved ✓'; } catch (error) { - alert(error.message || 'Failed to save workspace settings'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to save workspace settings', 'Workspace save failed'); + } saveButton.textContent = original; } finally { setTimeout(function () { @@ -268,11 +285,65 @@ async function loadWorkspaceSettings() { } } +function bindDangerZoneActions() { + var renameButton = document.getElementById('settings-ws-rename-btn'); + var exportButton = document.getElementById('settings-ws-export-btn'); + var deleteButton = document.getElementById('settings-ws-delete-btn'); + + if (renameButton && renameButton.dataset.bound !== 'true') { + renameButton.dataset.bound = 'true'; + renameButton.addEventListener('click', function() { + document.getElementById('settings-ws-slug').focus(); + if (window.CrankUi) { + window.CrankUi.info('Update the workspace slug above and save changes to rename it.', 'Rename workspace'); + } + }); + } + + if (exportButton && exportButton.dataset.bound !== 'true') { + exportButton.dataset.bound = 'true'; + exportButton.addEventListener('click', async function() { + var workspaceId = settingsWorkspaceId(); + if (!workspaceId || !window.CrankApi) return; + try { + var snapshot = await window.CrankApi.exportWorkspace(workspaceId); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + downloadSettingsJson((workspace ? workspace.slug : 'workspace') + '-snapshot.json', snapshot); + if (window.CrankUi) { + window.CrankUi.success('Workspace export downloaded.', 'Export complete'); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed'); + } + } + }); + } + + if (deleteButton && deleteButton.dataset.bound !== 'true') { + deleteButton.dataset.bound = 'true'; + deleteButton.addEventListener('click', async function() { + var workspaceId = settingsWorkspaceId(); + if (!workspaceId || !window.CrankApi || !window.CrankAuth) return; + if (!window.confirm('Delete workspace? This cannot be undone.')) return; + try { + await window.CrankApi.deleteWorkspace(workspaceId); + await window.CrankAuth.logout(); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed'); + } + } + }); + } +} + document.addEventListener('DOMContentLoaded', function () { injectLanguageSwitcher(); bindSectionNavigation(); loadProfile(); bindProfileSave(); bindPasswordSave(); + bindDangerZoneActions(); loadWorkspaceSettings(); }); diff --git a/apps/ui/js/ui-feedback.js b/apps/ui/js/ui-feedback.js new file mode 100644 index 0000000..9bdcf56 --- /dev/null +++ b/apps/ui/js/ui-feedback.js @@ -0,0 +1,70 @@ +(function() { + var CONTAINER_ID = 'crank-toast-container'; + var nextToastId = 0; + + function ensureContainer() { + var container = document.getElementById(CONTAINER_ID); + if (container) return container; + + container = document.createElement('div'); + container.id = CONTAINER_ID; + container.className = 'toast-stack'; + document.body.appendChild(container); + return container; + } + + function dismiss(toast) { + if (!toast || !toast.parentNode) return; + toast.classList.add('closing'); + window.setTimeout(function() { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 180); + } + + function notify(options) { + var config = options || {}; + var duration = config.duration === 0 ? 0 : (config.duration || 3600); + var toast = document.createElement('div'); + + toast.id = 'toast_' + (++nextToastId); + toast.className = 'toast-card toast-' + (config.type || 'info'); + toast.innerHTML = + '
' + + (config.title ? '
' + config.title + '
' : '') + + (config.message ? '
' + config.message + '
' : '') + + '
' + + ''; + + toast.querySelector('.toast-close').addEventListener('click', function() { + dismiss(toast); + }); + + ensureContainer().appendChild(toast); + + if (duration > 0) { + window.setTimeout(function() { + dismiss(toast); + }, duration); + } + + return toast.id; + } + + window.CrankUi = { + notify: notify, + success: function(message, title) { + return notify({ type: 'success', title: title || 'Done', message: message }); + }, + error: function(message, title) { + return notify({ type: 'error', title: title || 'Request failed', message: message }); + }, + info: function(message, title) { + return notify({ type: 'info', title: title || 'Info', message: message }); + } + }; +}()); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index 673bd3d..5c4fb11 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -1179,7 +1179,9 @@ async function saveOperation(stayOnPage) { try { await persistCurrentDraft(stayOnPage); } catch (error) { - alert(error.message || 'Failed to save operation'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to save operation', 'Save failed'); + } } } diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js index 2762c5b..a518b91 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -342,7 +342,9 @@ async function submitForm() { submit.disabled = false; }, 1400); } catch (error) { - alert(error.message || 'Failed to save workspace'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to save workspace', 'Save failed'); + } submit.disabled = false; submit.textContent = originalLabel; } @@ -404,7 +406,9 @@ async function sendInvite() { document.getElementById('invite-success').style.display = ''; await loadWorkspaceAccessData(); } catch (error) { - alert(error.message || 'Failed to send invite'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to send invite', 'Invite failed'); + } } } @@ -424,7 +428,9 @@ async function updateRole(select, userId) { renderMembers(); } catch (error) { select.value = previous; - alert(error.message || 'Failed to update role'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to update role', 'Role update failed'); + } } finally { select.disabled = false; } @@ -442,7 +448,9 @@ async function removeMember(userId, name) { await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId); await loadWorkspaceAccessData(); } catch (error) { - alert(error.message || 'Failed to remove member'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to remove member', 'Member removal failed'); + } } } @@ -455,7 +463,9 @@ async function revokeInviteById(invitationId) { await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId); await loadWorkspaceAccessData(); } catch (error) { - alert(error.message || 'Failed to revoke invite'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to revoke invite', 'Invite revoke failed'); + } } } @@ -489,7 +499,9 @@ async function exportWorkspaceSnapshot() { : 'workspace'; downloadJsonFile(slug + '-snapshot.json', snapshot); } catch (error) { - alert(error.message || 'Failed to export workspace'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed'); + } } } @@ -505,7 +517,9 @@ async function deleteWorkspaceAction() { await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId); await window.CrankAuth.logout(); } catch (error) { - alert(error.message || 'Failed to delete workspace'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed'); + } } } @@ -536,7 +550,9 @@ async function initPage() { applyWorkspaceRecord(record); await loadWorkspaceAccessData(); } catch (error) { - alert(error.message || 'Failed to load workspace'); + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to load workspace', 'Workspace load failed'); + } } }