feat: Add loading spinners to miner action buttons

- Show spinning indicator while start/stop actions are in progress
- Disable buttons during loading to prevent double-clicks
- Applied to: start profile, stop worker, context menu stop
- Spinner uses CSS animation for smooth visual feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-31 04:48:15 +00:00
parent ce5c5034bf
commit dc532d239e

View file

@ -9,6 +9,11 @@ interface ContextMenuState {
minerName: string;
}
// Spinner SVG for loading states
const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>`;
@Component({
selector: 'app-miner-switcher',
standalone: true,
@ -64,12 +69,20 @@ interface ContextMenuState {
<div class="miner-actions">
<button
class="action-btn stop"
[class.loading]="isLoading('stop-' + miner.name)"
[disabled]="isLoading('stop-' + miner.name)"
title="Stop miner"
(click)="stopMiner($event, miner.name)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
@if (isLoading('stop-' + miner.name)) {
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
}
</button>
<button
class="action-btn edit"
@ -97,11 +110,21 @@ interface ContextMenuState {
<div class="start-section">
<span class="section-label">Start Worker</span>
@for (profile of profiles(); track profile.id) {
<button class="dropdown-item start-item" (click)="startProfile(profile.id, profile.name)">
<svg class="item-icon play" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<button
class="dropdown-item start-item"
[class.loading]="isLoading('start-' + profile.id)"
[disabled]="isLoading('start-' + profile.id)"
(click)="startProfile(profile.id, profile.name)">
@if (isLoading('start-' + profile.id)) {
<svg class="item-icon spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<svg class="item-icon play" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
}
<span>{{ profile.name }}</span>
<span class="profile-type">{{ profile.minerType }}</span>
</button>
@ -152,11 +175,21 @@ interface ContextMenuState {
<span>Edit Configuration</span>
</button>
<div class="context-menu-divider"></div>
<button class="context-menu-item danger" (click)="stopFromContext()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
<button
class="context-menu-item danger"
[class.loading]="isLoading('stop-' + contextMenu().minerName)"
[disabled]="isLoading('stop-' + contextMenu().minerName)"
(click)="stopFromContext()">
@if (isLoading('stop-' + contextMenu().minerName)) {
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
} @else {
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
}
<span>Stop Worker</span>
</button>
</div>
@ -490,6 +523,38 @@ interface ContextMenuState {
.context-menu-item.danger svg {
color: var(--color-danger-500);
}
/* Spinner animation */
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Loading state styles */
.action-btn.loading,
.dropdown-item.loading,
.context-menu-item.loading {
opacity: 0.7;
cursor: not-allowed;
pointer-events: none;
}
.action-btn:disabled,
.dropdown-item:disabled,
.context-menu-item:disabled {
cursor: not-allowed;
pointer-events: none;
}
.action-btn.loading .spinner,
.dropdown-item.loading .spinner,
.context-menu-item.loading .spinner {
color: var(--color-accent-400);
}
`]
})
export class MinerSwitcherComponent {
@ -505,6 +570,25 @@ export class MinerSwitcherComponent {
dropdownOpen = signal(false);
contextMenu = signal<ContextMenuState>({ visible: false, x: 0, y: 0, minerName: '' });
// Track loading states for actions (e.g., "stop-minerName", "start-profileId")
private loadingActions = signal<Set<string>>(new Set());
isLoading(actionKey: string): boolean {
return this.loadingActions().has(actionKey);
}
private setLoading(actionKey: string, loading: boolean) {
this.loadingActions.update(set => {
const newSet = new Set(set);
if (loading) {
newSet.add(actionKey);
} else {
newSet.delete(actionKey);
}
return newSet;
});
}
// Close context menu on Escape
@HostListener('document:keydown.escape')
onEscape() {
@ -544,12 +628,20 @@ export class MinerSwitcherComponent {
stopMiner(event: Event, name: string) {
event.stopPropagation();
const actionKey = `stop-${name}`;
if (this.isLoading(actionKey)) return;
this.setLoading(actionKey, true);
this.minerService.stopMiner(name).subscribe({
next: () => {
// If this was the selected miner, switch to all view
if (this.selectedMinerName() === name) {
this.minerService.selectAllMiners();
}
this.setLoading(actionKey, false);
},
error: () => {
this.setLoading(actionKey, false);
}
});
}
@ -565,8 +657,19 @@ export class MinerSwitcherComponent {
}
startProfile(profileId: string, profileName: string) {
this.minerService.startMiner(profileId).subscribe();
this.closeDropdown();
const actionKey = `start-${profileId}`;
if (this.isLoading(actionKey)) return;
this.setLoading(actionKey, true);
this.minerService.startMiner(profileId).subscribe({
next: () => {
this.setLoading(actionKey, false);
this.closeDropdown();
},
error: () => {
this.setLoading(actionKey, false);
}
});
}
getHashrate(miner: any): number {
@ -632,13 +735,22 @@ export class MinerSwitcherComponent {
stopFromContext() {
const minerName = this.contextMenu().minerName;
const actionKey = `stop-${minerName}`;
if (this.isLoading(actionKey)) return;
this.setLoading(actionKey, true);
this.minerService.stopMiner(minerName).subscribe({
next: () => {
if (this.selectedMinerName() === minerName) {
this.minerService.selectAllMiners();
}
this.setLoading(actionKey, false);
this.closeContextMenu();
},
error: () => {
this.setLoading(actionKey, false);
this.closeContextMenu();
}
});
this.closeContextMenu();
}
}