mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
feat: Enhance achievement system with canvas-confetti and share functionality
- Replace custom CSS confetti with canvas-confetti library for massive Raycast-style explosions (850+ initial particles with continuous bursts) - Add circular progress ring around Maestro icon showing 11 badge segments - Add share button to AchievementCard with copy-to-clipboard and download - Fire confetti immediately on Standing Ovation mount and on dismiss - Fix infinite loop error in PlaygroundPanel layer registration - Fix badge tooltip positioning and click-to-toggle behavior - Improve empty badge cell visibility with dashed borders Claude ID: 97a10f0d-145d-4352-babd-6d9caed0f9dc Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
19
package-lock.json
generated
19
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@fastify/websocket": "^9.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"diff": "^8.0.2",
|
||||
"dompurify": "^3.3.0",
|
||||
"electron-store": "^8.1.0",
|
||||
@@ -35,6 +36,7 @@
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.2.47",
|
||||
@@ -2100,6 +2102,13 @@
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/canvas-confetti": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
@@ -3604,6 +3613,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"@fastify/websocket": "^9.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"diff": "^8.0.2",
|
||||
"dompurify": "^3.3.0",
|
||||
"electron-store": "^8.1.0",
|
||||
@@ -119,6 +120,7 @@
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.2.47",
|
||||
|
||||
@@ -2990,6 +2990,12 @@ export default function MaestroConsole() {
|
||||
else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('files'); ctx.setActiveFocus('right'); }
|
||||
else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('history'); ctx.setActiveFocus('right'); }
|
||||
else if (ctx.isShortcut(e, 'goToScratchpad')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('scratchpad'); ctx.setActiveFocus('right'); }
|
||||
else if (ctx.isShortcut(e, 'openImageCarousel')) {
|
||||
e.preventDefault();
|
||||
if (ctx.stagedImages.length > 0) {
|
||||
ctx.handleSetLightboxImage(ctx.stagedImages[0], ctx.stagedImages);
|
||||
}
|
||||
}
|
||||
else if (ctx.isShortcut(e, 'focusInput')) {
|
||||
e.preventDefault();
|
||||
ctx.setActiveFocus('main');
|
||||
@@ -3613,7 +3619,7 @@ export default function MaestroConsole() {
|
||||
setSessions, createTab, closeTab, reopenClosedTab, getActiveTab, setRenameTabId, setRenameTabInitialName,
|
||||
setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab,
|
||||
setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter,
|
||||
setTabSwitcherOpen, showUnreadOnly
|
||||
setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Trophy, Clock, Zap, Star, ExternalLink, ChevronDown, History } from 'lucide-react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Trophy, Clock, Zap, Star, ExternalLink, ChevronDown, History, Share2, Copy, Download, Check } from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import type { AutoRunStats } from '../types';
|
||||
import {
|
||||
@@ -245,7 +245,10 @@ function BadgeTooltip({ badge, theme, isUnlocked, position, onClose }: BadgeTool
|
||||
export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: AchievementCardProps & { onEscapeWithBadgeOpen?: (handler: (() => boolean) | null) => void }) {
|
||||
const [selectedBadge, setSelectedBadge] = useState<number | null>(null);
|
||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const badgeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const shareMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Register escape handler with parent when badge is selected
|
||||
useEffect(() => {
|
||||
@@ -299,6 +302,191 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
|
||||
);
|
||||
|
||||
const currentLevel = currentBadge?.level || 0;
|
||||
const goldColor = '#FFD700';
|
||||
const purpleAccent = theme.colors.accent;
|
||||
|
||||
// Close share menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!shareMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
|
||||
setShareMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
// Helper to wrap text for canvas
|
||||
const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
words.forEach(word => {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > maxWidth && currentLine) {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
});
|
||||
if (currentLine) lines.push(currentLine);
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Generate shareable achievement card as canvas
|
||||
const generateShareImage = useCallback(async (): Promise<HTMLCanvasElement> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const width = 600;
|
||||
const height = 400;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Background gradient
|
||||
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
|
||||
bgGradient.addColorStop(0, '#1a1a2e');
|
||||
bgGradient.addColorStop(1, '#16213e');
|
||||
ctx.fillStyle = bgGradient;
|
||||
ctx.roundRect(0, 0, width, height, 16);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = goldColor;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.roundRect(0, 0, width, height, 16);
|
||||
ctx.stroke();
|
||||
|
||||
// Header accent
|
||||
const headerGradient = ctx.createLinearGradient(0, 0, width, 100);
|
||||
headerGradient.addColorStop(0, `${purpleAccent}40`);
|
||||
headerGradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = headerGradient;
|
||||
ctx.fillRect(0, 0, width, 100);
|
||||
|
||||
// Trophy icon (simplified circle)
|
||||
ctx.beginPath();
|
||||
ctx.arc(width / 2, 60, 30, 0, Math.PI * 2);
|
||||
const trophyGradient = ctx.createRadialGradient(width / 2, 60, 0, width / 2, 60, 30);
|
||||
trophyGradient.addColorStop(0, '#FFA500');
|
||||
trophyGradient.addColorStop(1, goldColor);
|
||||
ctx.fillStyle = trophyGradient;
|
||||
ctx.fill();
|
||||
|
||||
// Trophy emoji
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 28px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🏆', width / 2, 70);
|
||||
|
||||
// Title
|
||||
ctx.font = 'bold 24px system-ui';
|
||||
ctx.fillStyle = goldColor;
|
||||
ctx.fillText('MAESTRO ACHIEVEMENTS', width / 2, 120);
|
||||
|
||||
if (currentBadge) {
|
||||
// Level badge
|
||||
ctx.font = 'bold 18px system-ui';
|
||||
ctx.fillStyle = goldColor;
|
||||
ctx.fillText(`⭐ Level ${currentBadge.level} of 11 ⭐`, width / 2, 155);
|
||||
|
||||
// Badge name
|
||||
ctx.font = 'bold 28px system-ui';
|
||||
ctx.fillStyle = purpleAccent;
|
||||
ctx.fillText(currentBadge.name, width / 2, 190);
|
||||
|
||||
// Flavor text
|
||||
ctx.font = 'italic 14px system-ui';
|
||||
ctx.fillStyle = '#CCCCCC';
|
||||
const flavorLines = wrapText(ctx, `"${currentBadge.flavorText}"`, width - 80);
|
||||
let yOffset = 225;
|
||||
flavorLines.forEach(line => {
|
||||
ctx.fillText(line, width / 2, yOffset);
|
||||
yOffset += 18;
|
||||
});
|
||||
} else {
|
||||
// No badge yet
|
||||
ctx.font = 'bold 20px system-ui';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.fillText('Journey Just Beginning...', width / 2, 170);
|
||||
|
||||
ctx.font = '14px system-ui';
|
||||
ctx.fillStyle = '#888888';
|
||||
ctx.fillText('Complete 15 minutes of AutoRun to unlock first badge', width / 2, 200);
|
||||
}
|
||||
|
||||
// Stats box
|
||||
const statsY = 300;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.roundRect(50, statsY - 10, width - 100, 50, 8);
|
||||
ctx.fill();
|
||||
|
||||
ctx.font = '14px system-ui';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Total AutoRun:', 70, statsY + 15);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 14px system-ui';
|
||||
ctx.fillText(formatCumulativeTime(autoRunStats.cumulativeTimeMs), 180, statsY + 15);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px system-ui';
|
||||
ctx.fillText('Longest Run:', 350, statsY + 15);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 14px system-ui';
|
||||
ctx.fillText(formatCumulativeTime(autoRunStats.longestRunMs), 450, statsY + 15);
|
||||
|
||||
// Footer branding
|
||||
ctx.font = 'bold 12px system-ui';
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('MAESTRO • Agent Orchestration Command Center', width / 2, height - 20);
|
||||
|
||||
return canvas;
|
||||
}, [currentBadge, autoRunStats.cumulativeTimeMs, autoRunStats.longestRunMs, purpleAccent]);
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
try {
|
||||
const canvas = await generateShareImage();
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
]);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
}
|
||||
}, [generateShareImage]);
|
||||
|
||||
// Download as image
|
||||
const downloadImage = useCallback(async () => {
|
||||
try {
|
||||
const canvas = await generateShareImage();
|
||||
const link = document.createElement('a');
|
||||
link.download = `maestro-achievement-level-${currentLevel}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
}
|
||||
}, [generateShareImage, currentLevel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -309,11 +497,62 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Trophy className="w-4 h-4" style={{ color: '#FFD700' }} />
|
||||
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
|
||||
Maestro Achievements
|
||||
</span>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" style={{ color: '#FFD700' }} />
|
||||
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
|
||||
Maestro Achievements
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Share button */}
|
||||
<div className="relative" ref={shareMenuRef}>
|
||||
<button
|
||||
onClick={() => setShareMenuOpen(!shareMenuOpen)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Share achievements"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{shareMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 p-1.5 rounded-lg shadow-xl z-50 min-w-[160px]"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSidebar,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard();
|
||||
setShareMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{copySuccess ? (
|
||||
<Check className="w-4 h-4" style={{ color: theme.colors.success }} />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
)}
|
||||
<span style={{ color: theme.colors.textMain }}>
|
||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
downloadImage();
|
||||
setShareMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
<span style={{ color: theme.colors.textMain }}>Save as Image</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current badge display */}
|
||||
@@ -476,9 +715,7 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
|
||||
onClick={() => setSelectedBadge(isSelected ? null : badge.level)}
|
||||
>
|
||||
<div
|
||||
className={`h-3 rounded-full cursor-pointer transition-all hover:scale-110 ${
|
||||
isCurrent ? 'ring-2 ring-offset-1' : ''
|
||||
}`}
|
||||
className="h-3 rounded-full cursor-pointer transition-all hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: isUnlocked
|
||||
? badge.level <= 3
|
||||
@@ -487,9 +724,9 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
|
||||
? '#FFD700'
|
||||
: '#FF6B35'
|
||||
: theme.colors.border,
|
||||
ringColor: isCurrent ? '#FFD700' : 'transparent',
|
||||
opacity: isUnlocked ? 1 : 0.5,
|
||||
border: isUnlocked ? 'none' : `1px dashed ${theme.colors.textDim}`,
|
||||
boxShadow: isCurrent ? `0 0 0 2px ${theme.colors.bgActivity}, 0 0 0 4px #FFD700` : 'none',
|
||||
}}
|
||||
title={`${badge.name} - Click to view details`}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, Bot, User, Copy, Check, CheckCircle, XCircle, Trash2, Clock, Cpu, Zap, Play } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, Bot, User, Copy, Check, CheckCircle, XCircle, Trash2, Clock, Cpu, Zap, Play, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { Theme, HistoryEntry } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -33,6 +33,10 @@ interface HistoryDetailModalProps {
|
||||
onResumeSession?: (claudeSessionId: string) => void;
|
||||
onDelete?: (entryId: string) => void;
|
||||
onUpdate?: (entryId: string, updates: { validated?: boolean }) => Promise<boolean>;
|
||||
// Navigation props for prev/next
|
||||
filteredEntries?: HistoryEntry[];
|
||||
currentIndex?: number;
|
||||
onNavigate?: (entry: HistoryEntry, index: number) => void;
|
||||
}
|
||||
|
||||
// Get context bar color based on usage percentage
|
||||
@@ -49,7 +53,10 @@ export function HistoryDetailModal({
|
||||
onJumpToClaudeSession,
|
||||
onResumeSession,
|
||||
onDelete,
|
||||
onUpdate
|
||||
onUpdate,
|
||||
filteredEntries,
|
||||
currentIndex,
|
||||
onNavigate
|
||||
}: HistoryDetailModalProps) {
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
@@ -59,6 +66,26 @@ export function HistoryDetailModal({
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Navigation state
|
||||
const canNavigate = filteredEntries && currentIndex !== undefined && onNavigate;
|
||||
const hasPrev = canNavigate && currentIndex > 0;
|
||||
const hasNext = canNavigate && currentIndex < filteredEntries.length - 1;
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrev = useCallback(() => {
|
||||
if (hasPrev && filteredEntries && onNavigate) {
|
||||
const newIndex = currentIndex! - 1;
|
||||
onNavigate(filteredEntries[newIndex], newIndex);
|
||||
}
|
||||
}, [hasPrev, filteredEntries, currentIndex, onNavigate]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (hasNext && filteredEntries && onNavigate) {
|
||||
const newIndex = currentIndex! + 1;
|
||||
onNavigate(filteredEntries[newIndex], newIndex);
|
||||
}
|
||||
}, [hasNext, filteredEntries, currentIndex, onNavigate]);
|
||||
|
||||
// Register layer on mount
|
||||
useEffect(() => {
|
||||
const id = registerLayer({
|
||||
@@ -93,6 +120,25 @@ export function HistoryDetailModal({
|
||||
}
|
||||
}, [showDeleteConfirm]);
|
||||
|
||||
// Keyboard navigation for prev/next with arrow keys
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle if delete confirmation is showing
|
||||
if (showDeleteConfirm) return;
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goToPrev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goToNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [goToPrev, goToNext, showDeleteConfirm]);
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
@@ -369,7 +415,7 @@ export function HistoryDetailModal({
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex justify-between px-6 py-4 border-t shrink-0"
|
||||
className="flex items-center justify-between px-6 py-4 border-t shrink-0"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* Delete button */}
|
||||
@@ -387,6 +433,44 @@ export function HistoryDetailModal({
|
||||
Delete
|
||||
</button>
|
||||
|
||||
{/* Prev/Next navigation buttons - centered */}
|
||||
{canNavigate && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={!hasPrev}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: hasPrev ? theme.colors.bgActivity : 'transparent',
|
||||
color: hasPrev ? theme.colors.textMain : theme.colors.textDim,
|
||||
border: `1px solid ${hasPrev ? theme.colors.border : theme.colors.border + '40'}`,
|
||||
opacity: hasPrev ? 1 : 0.4,
|
||||
cursor: hasPrev ? 'pointer' : 'default'
|
||||
}}
|
||||
title={hasPrev ? 'Previous entry (←)' : 'No previous entry'}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={!hasNext}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: hasNext ? theme.colors.bgActivity : 'transparent',
|
||||
color: hasNext ? theme.colors.textMain : theme.colors.textDim,
|
||||
border: `1px solid ${hasNext ? theme.colors.border : theme.colors.border + '40'}`,
|
||||
opacity: hasNext ? 1 : 0.4,
|
||||
cursor: hasNext ? 'pointer' : 'default'
|
||||
}}
|
||||
title={hasNext ? 'Next entry (→)' : 'No next entry'}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded text-sm font-medium transition-colors hover:opacity-90"
|
||||
|
||||
@@ -846,6 +846,17 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
// Navigation props - use allFilteredEntries (respects filters)
|
||||
filteredEntries={allFilteredEntries}
|
||||
currentIndex={selectedIndex}
|
||||
onNavigate={(entry, index) => {
|
||||
setSelectedIndex(index);
|
||||
setDetailModalEntry(entry);
|
||||
// Ensure the entry is visible in the list (expand displayCount if needed)
|
||||
if (index >= displayCount) {
|
||||
setDisplayCount(Math.min(index + LOAD_MORE_COUNT, allFilteredEntries.length));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -652,9 +652,9 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
|
||||
tabSaveToHistory ? '' : 'opacity-40 hover:opacity-70'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: tabSaveToHistory ? `${theme.colors.info}25` : 'transparent',
|
||||
color: tabSaveToHistory ? theme.colors.info : theme.colors.textDim,
|
||||
border: tabSaveToHistory ? `1px solid ${theme.colors.info}50` : '1px solid transparent'
|
||||
backgroundColor: tabSaveToHistory ? `${theme.colors.accent}25` : 'transparent',
|
||||
color: tabSaveToHistory ? theme.colors.accent : theme.colors.textDim,
|
||||
border: tabSaveToHistory ? `1px solid ${theme.colors.accent}50` : '1px solid transparent'
|
||||
}}
|
||||
title="Save to History (Cmd+S) - Synopsis added after each completion"
|
||||
>
|
||||
|
||||
@@ -243,12 +243,23 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
// Look up Claude session ID from the tab if this is an AI process
|
||||
let claudeSessionId: string | undefined;
|
||||
let tabId: string | undefined;
|
||||
if (processType === 'ai') {
|
||||
if (processType === 'ai' || processType === 'batch' || processType === 'synopsis') {
|
||||
tabId = parseTabId(proc.sessionId) || undefined;
|
||||
if (tabId && session.aiTabs) {
|
||||
const tab = session.aiTabs.find(t => t.id === tabId);
|
||||
if (tab?.claudeSessionId) {
|
||||
claudeSessionId = tab.claudeSessionId;
|
||||
if (session.aiTabs) {
|
||||
// First try to find by tab ID
|
||||
if (tabId) {
|
||||
const tab = session.aiTabs.find(t => t.id === tabId);
|
||||
if (tab?.claudeSessionId) {
|
||||
claudeSessionId = tab.claudeSessionId;
|
||||
}
|
||||
}
|
||||
// Fall back to active tab if no tab ID match
|
||||
if (!claudeSessionId) {
|
||||
const activeTab = session.aiTabs.find(t => t.id === session.activeTabId);
|
||||
if (activeTab?.claudeSessionId) {
|
||||
claudeSessionId = activeTab.claudeSessionId;
|
||||
tabId = activeTab.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,59 +279,48 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
});
|
||||
});
|
||||
|
||||
// If no active processes, show placeholder based on session state
|
||||
if (sessionNode.children!.length === 0) {
|
||||
// Show what could be running based on session type
|
||||
if (session.toolType !== 'terminal') {
|
||||
sessionNode.children!.push({
|
||||
id: `process-${session.id}-ai-inactive`,
|
||||
type: 'process',
|
||||
label: `AI Agent (${session.toolType})`,
|
||||
pid: session.aiPid > 0 ? session.aiPid : undefined,
|
||||
processType: 'ai',
|
||||
sessionId: session.id,
|
||||
isAlive: false
|
||||
});
|
||||
}
|
||||
sessionNode.children!.push({
|
||||
id: `process-${session.id}-terminal-inactive`,
|
||||
type: 'process',
|
||||
label: 'Terminal Shell',
|
||||
pid: session.terminalPid > 0 ? session.terminalPid : undefined,
|
||||
processType: 'terminal',
|
||||
sessionId: session.id,
|
||||
isAlive: false
|
||||
});
|
||||
}
|
||||
|
||||
// Only return session node if it has active processes
|
||||
return sessionNode;
|
||||
};
|
||||
|
||||
// Add grouped sessions
|
||||
// Add grouped sessions (only include sessions with active processes)
|
||||
groups.forEach(group => {
|
||||
const groupSessions = sessionsByGroup.get(group.id) || [];
|
||||
const groupNode: ProcessNode = {
|
||||
id: `group-${group.id}`,
|
||||
type: 'group',
|
||||
label: group.name,
|
||||
emoji: group.emoji,
|
||||
expanded: expandedNodes.has(`group-${group.id}`),
|
||||
children: groupSessions.map(session => buildSessionNode(session))
|
||||
};
|
||||
tree.push(groupNode);
|
||||
const sessionNodes = groupSessions
|
||||
.map(session => buildSessionNode(session))
|
||||
.filter(node => node.children && node.children.length > 0);
|
||||
|
||||
// Only add group if it has sessions with active processes
|
||||
if (sessionNodes.length > 0) {
|
||||
const groupNode: ProcessNode = {
|
||||
id: `group-${group.id}`,
|
||||
type: 'group',
|
||||
label: group.name,
|
||||
emoji: group.emoji,
|
||||
expanded: expandedNodes.has(`group-${group.id}`),
|
||||
children: sessionNodes
|
||||
};
|
||||
tree.push(groupNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add ungrouped sessions (root level)
|
||||
// Add ungrouped sessions (root level, only with active processes)
|
||||
if (ungroupedSessions.length > 0) {
|
||||
const rootNode: ProcessNode = {
|
||||
id: 'group-root',
|
||||
type: 'group',
|
||||
label: 'UNGROUPED',
|
||||
emoji: '📁',
|
||||
expanded: expandedNodes.has('group-root'),
|
||||
children: ungroupedSessions.map(session => buildSessionNode(session))
|
||||
};
|
||||
tree.push(rootNode);
|
||||
const sessionNodes = ungroupedSessions
|
||||
.map(session => buildSessionNode(session))
|
||||
.filter(node => node.children && node.children.length > 0);
|
||||
|
||||
if (sessionNodes.length > 0) {
|
||||
const rootNode: ProcessNode = {
|
||||
id: 'group-root',
|
||||
type: 'group',
|
||||
label: 'UNGROUPED',
|
||||
emoji: '📁',
|
||||
expanded: expandedNodes.has('group-root'),
|
||||
children: sessionNodes
|
||||
};
|
||||
tree.push(rootNode);
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
@@ -535,9 +535,6 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
}
|
||||
|
||||
if (node.type === 'process') {
|
||||
const statusColor = node.isAlive ? theme.colors.success : theme.colors.textDim;
|
||||
const statusText = node.isAlive ? 'Running' : 'Idle';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isSelected ? selectedNodeRef as React.RefObject<HTMLDivElement> : null}
|
||||
@@ -558,7 +555,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
<div className="w-4 h-4 flex-shrink-0" />
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
/>
|
||||
<span className="text-sm flex-1 truncate">{node.label}</span>
|
||||
{node.claudeSessionId && node.sessionId && onNavigateToSession && (
|
||||
@@ -581,16 +578,16 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
|
||||
{node.pid ? `PID: ${node.pid}` : 'No PID'}
|
||||
PID: {node.pid}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: node.isAlive ? `${theme.colors.success}20` : `${theme.colors.textDim}20`,
|
||||
color: statusColor
|
||||
backgroundColor: `${theme.colors.success}20`,
|
||||
color: theme.colors.success
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
Running
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -702,7 +699,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
className="px-6 py-8 text-center"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
No active sessions
|
||||
No running processes
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
@@ -726,8 +723,6 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: theme.colors.success }} />
|
||||
<span>Running</span>
|
||||
<div className="w-2 h-2 rounded-full ml-3" style={{ backgroundColor: theme.colors.textDim }} />
|
||||
<span>Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { ExternalLink, Trophy, Clock, Star, Share2, Copy, Download, Check } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import type { Theme, ThemeMode } from '../types';
|
||||
import type { ConductorBadge } from '../constants/conductorBadges';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
@@ -7,22 +8,6 @@ import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { AnimatedMaestro } from './MaestroSilhouette';
|
||||
import { formatCumulativeTime, formatTimeRemaining, getNextBadge } from '../constants/conductorBadges';
|
||||
|
||||
// Confetti particle type
|
||||
interface ConfettiParticle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
color: string;
|
||||
size: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
rotationSpeed: number;
|
||||
delay: number;
|
||||
shape: 'rect' | 'square' | 'circle' | 'star';
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface StandingOvationOverlayProps {
|
||||
theme: Theme;
|
||||
themeMode: ThemeMode;
|
||||
@@ -52,6 +37,186 @@ export function StandingOvationOverlay({
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Ref for the close handler that includes confetti animation
|
||||
const handleCloseRef = useRef<() => void>(() => {});
|
||||
|
||||
// State
|
||||
const nextBadge = getNextBadge(badge);
|
||||
const isDark = themeMode === 'dark';
|
||||
const maestroVariant = isDark ? 'light' : 'dark';
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Accent colors
|
||||
const goldColor = '#FFD700';
|
||||
const purpleAccent = theme.colors.accent;
|
||||
|
||||
// Confetti colors matching our theme
|
||||
const confettiColors = [
|
||||
goldColor, purpleAccent,
|
||||
'#FF6B6B', '#FF8E53', '#FFA726', // Warm colors
|
||||
'#4ECDC4', '#45B7D1', '#64B5F6', // Cool colors
|
||||
'#96CEB4', '#81C784', // Greens
|
||||
'#FFEAA7', '#FFD54F', // Yellows
|
||||
'#DDA0DD', '#BA68C8', '#9575CD', // Purples
|
||||
'#F48FB1', '#FF80AB', // Pinks
|
||||
];
|
||||
|
||||
// Fire confetti burst - MASSIVE Raycast style explosion
|
||||
const fireConfetti = useCallback(() => {
|
||||
const duration = 4000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 55,
|
||||
spread: 360,
|
||||
ticks: 200,
|
||||
zIndex: 999999,
|
||||
colors: confettiColors,
|
||||
disableForReducedMotion: true,
|
||||
gravity: 0.8,
|
||||
};
|
||||
|
||||
// Helper to get random value in range
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
// INITIAL MASSIVE BURST - fire immediately from multiple points
|
||||
// Center explosion
|
||||
confetti({
|
||||
particleCount: 300,
|
||||
spread: 120,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 70,
|
||||
zIndex: 999999,
|
||||
scalar: 1.3,
|
||||
gravity: 0.7,
|
||||
ticks: 300,
|
||||
});
|
||||
|
||||
// Left burst
|
||||
confetti({
|
||||
particleCount: 200,
|
||||
spread: 80,
|
||||
origin: { x: 0.2, y: 0.5 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 65,
|
||||
zIndex: 999999,
|
||||
scalar: 1.2,
|
||||
angle: 60,
|
||||
gravity: 0.8,
|
||||
ticks: 250,
|
||||
});
|
||||
|
||||
// Right burst
|
||||
confetti({
|
||||
particleCount: 200,
|
||||
spread: 80,
|
||||
origin: { x: 0.8, y: 0.5 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 65,
|
||||
zIndex: 999999,
|
||||
scalar: 1.2,
|
||||
angle: 120,
|
||||
gravity: 0.8,
|
||||
ticks: 250,
|
||||
});
|
||||
|
||||
// Top burst
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 100,
|
||||
origin: { x: 0.5, y: 0.3 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 60,
|
||||
zIndex: 999999,
|
||||
scalar: 1.1,
|
||||
gravity: 1,
|
||||
ticks: 200,
|
||||
});
|
||||
|
||||
// Fire continuous bursts
|
||||
const interval = setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const particleCount = 250 * (timeLeft / duration);
|
||||
|
||||
// Fire from 5 random origins for maximum chaos
|
||||
for (let i = 0; i < 5; i++) {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: Math.floor(particleCount / 3),
|
||||
origin: { x: randomInRange(0.1, 0.9), y: randomInRange(0.2, 0.6) },
|
||||
scalar: randomInRange(0.8, 1.5),
|
||||
startVelocity: randomInRange(45, 70),
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Secondary wave after 200ms
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
particleCount: 250,
|
||||
spread: 140,
|
||||
origin: { x: 0.5, y: 0.45 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 75,
|
||||
zIndex: 999999,
|
||||
scalar: 1.4,
|
||||
gravity: 0.6,
|
||||
ticks: 300,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// Third wave
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
particleCount: 200,
|
||||
spread: 160,
|
||||
origin: { x: 0.3, y: 0.4 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 60,
|
||||
zIndex: 999999,
|
||||
scalar: 1.2,
|
||||
gravity: 0.9,
|
||||
});
|
||||
confetti({
|
||||
particleCount: 200,
|
||||
spread: 160,
|
||||
origin: { x: 0.7, y: 0.4 },
|
||||
colors: confettiColors,
|
||||
startVelocity: 60,
|
||||
zIndex: 999999,
|
||||
scalar: 1.2,
|
||||
gravity: 0.9,
|
||||
});
|
||||
}, 400);
|
||||
}, [confettiColors]);
|
||||
|
||||
// Fire confetti on mount - immediately!
|
||||
useEffect(() => {
|
||||
fireConfetti();
|
||||
}, [fireConfetti]);
|
||||
|
||||
// Handle graceful close with confetti
|
||||
const handleTakeABow = useCallback(() => {
|
||||
if (isClosing) return;
|
||||
setIsClosing(true);
|
||||
|
||||
// Fire closing confetti burst
|
||||
fireConfetti();
|
||||
|
||||
// Wait for confetti animation then close
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
}, [isClosing, onClose, fireConfetti]);
|
||||
|
||||
// Register with layer stack
|
||||
useEffect(() => {
|
||||
const id = registerLayer({
|
||||
@@ -61,7 +226,7 @@ export function StandingOvationOverlay({
|
||||
capturesFocus: true,
|
||||
focusTrap: 'strict',
|
||||
ariaLabel: 'Standing Ovation Achievement',
|
||||
onEscape: () => onCloseRef.current(),
|
||||
onEscape: () => handleCloseRef.current(),
|
||||
});
|
||||
layerIdRef.current = id;
|
||||
|
||||
@@ -74,77 +239,16 @@ export function StandingOvationOverlay({
|
||||
};
|
||||
}, [registerLayer, unregisterLayer]);
|
||||
|
||||
// Update close handler ref when handleTakeABow changes
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = handleTakeABow;
|
||||
}, [handleTakeABow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layerIdRef.current) {
|
||||
updateLayerHandler(layerIdRef.current, onClose);
|
||||
updateLayerHandler(layerIdRef.current, () => handleCloseRef.current());
|
||||
}
|
||||
}, [onClose, updateLayerHandler]);
|
||||
|
||||
const nextBadge = getNextBadge(badge);
|
||||
const isDark = themeMode === 'dark';
|
||||
const maestroVariant = isDark ? 'light' : 'dark';
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
// Accent colors
|
||||
const goldColor = '#FFD700';
|
||||
const purpleAccent = theme.colors.accent;
|
||||
|
||||
// Generate confetti particles - Ray Cast style explosion with multiple waves
|
||||
const confettiParticles = useMemo<ConfettiParticle[]>(() => {
|
||||
const particles: ConfettiParticle[] = [];
|
||||
const colors = [
|
||||
goldColor, purpleAccent,
|
||||
'#FF6B6B', '#FF8E53', '#FFA726', // Warm colors
|
||||
'#4ECDC4', '#45B7D1', '#64B5F6', // Cool colors
|
||||
'#96CEB4', '#81C784', // Greens
|
||||
'#FFEAA7', '#FFD54F', // Yellows
|
||||
'#DDA0DD', '#BA68C8', '#9575CD', // Purples
|
||||
'#F48FB1', '#FF80AB', // Pinks
|
||||
'#FFFFFF', '#E0E0E0', // Whites/silvers
|
||||
];
|
||||
|
||||
// Multiple burst waves for that explosive Ray Cast feel
|
||||
const waves = [
|
||||
{ count: 400, speedMin: 600, speedMax: 1200, delayBase: 0, delaySpread: 0.1 }, // Initial explosion
|
||||
{ count: 300, speedMin: 400, speedMax: 900, delayBase: 0.05, delaySpread: 0.15 }, // Second wave
|
||||
{ count: 200, speedMin: 300, speedMax: 700, delayBase: 0.1, delaySpread: 0.2 }, // Third wave
|
||||
{ count: 150, speedMin: 200, speedMax: 500, delayBase: 0.2, delaySpread: 0.3 }, // Slower trailing pieces
|
||||
{ count: 100, speedMin: 100, speedMax: 300, delayBase: 0.3, delaySpread: 0.5 }, // Floaty pieces
|
||||
];
|
||||
|
||||
let id = 0;
|
||||
|
||||
const shapes: Array<'rect' | 'square' | 'circle' | 'star'> = ['rect', 'rect', 'rect', 'square', 'square', 'circle', 'star'];
|
||||
|
||||
for (const wave of waves) {
|
||||
for (let i = 0; i < wave.count; i++) {
|
||||
// Random angle for burst direction (full 360 degrees)
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
// Random speed within wave's range
|
||||
const speed = wave.speedMin + Math.random() * (wave.speedMax - wave.speedMin);
|
||||
// Add some vertical bias for gravity feel
|
||||
const gravityBias = Math.random() * 150;
|
||||
|
||||
particles.push({
|
||||
id: id++,
|
||||
x: 50 + (Math.random() - 0.5) * 5, // Slight spread from center
|
||||
y: 50 + (Math.random() - 0.5) * 5,
|
||||
rotation: Math.random() * 360,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: 4 + Math.random() * 12, // Varied sizes
|
||||
velocityX: Math.cos(angle) * speed,
|
||||
velocityY: Math.sin(angle) * speed + gravityBias,
|
||||
rotationSpeed: (Math.random() - 0.5) * 1080, // More spin
|
||||
delay: wave.delayBase + Math.random() * wave.delaySpread,
|
||||
shape: shapes[Math.floor(Math.random() * shapes.length)],
|
||||
duration: 2.5 + Math.random() * 2.5, // 2.5-5 seconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return particles;
|
||||
}, [purpleAccent]);
|
||||
}, [updateLayerHandler]);
|
||||
|
||||
// Generate shareable achievement card as canvas
|
||||
const generateShareImage = useCallback(async (): Promise<HTMLCanvasElement> => {
|
||||
@@ -316,105 +420,21 @@ export function StandingOvationOverlay({
|
||||
aria-modal="true"
|
||||
aria-label="Standing Ovation Achievement"
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
onClick={handleTakeABow}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
}}
|
||||
>
|
||||
{/* Confetti burst effect - behind the modal (z-index: 0) */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
|
||||
{confettiParticles.map((particle) => {
|
||||
// Determine shape styling
|
||||
let width: string;
|
||||
let height: string;
|
||||
let borderRadius: string;
|
||||
let boxShadow: string;
|
||||
|
||||
switch (particle.shape) {
|
||||
case 'circle':
|
||||
width = `${particle.size}px`;
|
||||
height = `${particle.size}px`;
|
||||
borderRadius = '50%';
|
||||
boxShadow = `0 0 ${particle.size / 2}px ${particle.color}40`;
|
||||
break;
|
||||
case 'square':
|
||||
width = `${particle.size}px`;
|
||||
height = `${particle.size}px`;
|
||||
borderRadius = '2px';
|
||||
boxShadow = `0 0 ${particle.size / 3}px ${particle.color}30`;
|
||||
break;
|
||||
case 'star':
|
||||
width = `${particle.size}px`;
|
||||
height = `${particle.size}px`;
|
||||
borderRadius = '0';
|
||||
boxShadow = `0 0 ${particle.size}px ${particle.color}60`;
|
||||
break;
|
||||
case 'rect':
|
||||
default:
|
||||
width = `${particle.size}px`;
|
||||
height = `${particle.size * 0.4}px`;
|
||||
borderRadius = '1px';
|
||||
boxShadow = 'none';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={particle.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${particle.x}%`,
|
||||
top: `${particle.y}%`,
|
||||
width,
|
||||
height,
|
||||
backgroundColor: particle.color,
|
||||
borderRadius,
|
||||
boxShadow,
|
||||
transform: `rotate(${particle.rotation}deg)`,
|
||||
opacity: 0,
|
||||
animation: `confetti-burst ${particle.duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${particle.delay}s forwards`,
|
||||
// CSS custom properties for the animation
|
||||
'--vx': `${particle.velocityX}px`,
|
||||
'--vy': `${particle.velocityY}px`,
|
||||
'--rot': `${particle.rotationSpeed}deg`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Keyframe animation style - enhanced physics */}
|
||||
<style>{`
|
||||
@keyframes confetti-burst {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--vx), calc(var(--vy) + 600px)) rotate(var(--rot)) scale(0.5);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Main content card - z-index: 1 to be above confetti */}
|
||||
{/* Main content card */}
|
||||
<div
|
||||
className="relative max-w-lg w-full mx-4 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-500"
|
||||
className={`relative max-w-lg w-full mx-4 rounded-2xl shadow-2xl overflow-hidden transition-all duration-500 ${
|
||||
isClosing ? 'opacity-0 scale-95' : 'animate-in zoom-in-95'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSidebar,
|
||||
border: `2px solid ${goldColor}`,
|
||||
boxShadow: `0 0 40px rgba(0, 0, 0, 0.5)`,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Header with glow */}
|
||||
@@ -590,15 +610,16 @@ export function StandingOvationOverlay({
|
||||
{/* Buttons */}
|
||||
<div className="px-8 pb-8 space-y-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 rounded-lg font-medium transition-all hover:scale-[1.02]"
|
||||
onClick={handleTakeABow}
|
||||
disabled={isClosing}
|
||||
className="w-full py-3 rounded-lg font-medium transition-all hover:scale-[1.02] disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${purpleAccent} 0%, ${goldColor} 100%)`,
|
||||
color: '#FFFFFF',
|
||||
boxShadow: `0 4px 20px ${purpleAccent}40`,
|
||||
}}
|
||||
>
|
||||
Take a Bow
|
||||
{isClosing ? '🎉 Bravo! 🎉' : 'Take a Bow'}
|
||||
</button>
|
||||
|
||||
{/* Share options */}
|
||||
|
||||
@@ -570,9 +570,10 @@ export function TabBar({
|
||||
const unreadCount = tabs.filter(t => t.hasUnread).length;
|
||||
|
||||
// Filter tabs based on unread filter state
|
||||
// When filter is on, show ONLY unread tabs (can be empty)
|
||||
// When filter is on, show unread tabs + the active tab (so user sees what they're viewing)
|
||||
// The active tab disappears from the filtered list when user navigates away from it
|
||||
const displayedTabs = showUnreadOnly
|
||||
? tabs.filter(t => t.hasUnread)
|
||||
? tabs.filter(t => t.hasUnread || t.id === activeTabId)
|
||||
: tabs;
|
||||
|
||||
// Check if tabs overflow the container (need sticky + button)
|
||||
|
||||
@@ -29,6 +29,7 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
|
||||
jumpToBottom: { id: 'jumpToBottom', label: 'Jump to Bottom', keys: ['Meta', 'Shift', 'j'] },
|
||||
prevTab: { id: 'prevTab', label: 'Previous Tab', keys: ['Meta', 'Shift', '['] },
|
||||
nextTab: { id: 'nextTab', label: 'Next Tab', keys: ['Meta', 'Shift', ']'] },
|
||||
openImageCarousel: { id: 'openImageCarousel', label: 'Open Image Carousel', keys: ['Meta', 'y'] },
|
||||
};
|
||||
|
||||
// Non-editable shortcuts (displayed in help but not configurable)
|
||||
|
||||
Reference in New Issue
Block a user