MosswartOverlord/frontend/src/components/effects/RareNotification.tsx
Erik 0112c59514 feat(v2): 13 improvements — functional, visual, UX, backend
Functional:
1. Chat: "▼ New messages below" indicator when scrolled up, click to jump
2. Combat stats: "Clear Session" button (red, with confirm dialog)
3. Inventory: live updates via inventory_delta WS (re-fetches on change)
4. Inventory: real mana time from equipment_cantrip_state WS (live
   countdown with state dot: green=active, red=inactive, yellow=unknown)

Visual:
5. Thin separator line between tool links and sort buttons
6. Selected player row highlighted with darker background (#2a3344)
7. Scroll-to-top button (▲) appears when scrolled past 200px in player list

UX:
8. Double-click player dot on map opens their chat window
9. Right-click player dot shows context menu (Chat/Stats/Inv/Char/Combat/Radar)
10. Ctrl+D keyboard shortcut toggles between map and dashboard views
11. Sound notification on rare drops (880Hz sine beep via Web Audio API)

Backend:
12. Deep-merge lifetime offense/defense per element — accumulates
    total_attacks, failed_attacks, crits, damage per AttackType×Element
    instead of overwriting with latest session data
13. Startup cleanup: deletes stale combat_stats records from before
    the lifetime fix (pre-2026-04-14T09:00Z)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:49:40 +02:00

109 lines
3.7 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import type { RareMessage } from '../../types';
interface Props {
recentRares: RareMessage[];
}
interface ActiveNotification {
key: number;
charName: string;
rareName: string;
exiting: boolean;
}
let notifKey = 0;
export const RareNotification: React.FC<Props> = ({ recentRares }) => {
const [active, setActive] = useState<ActiveNotification[]>([]);
const [lastCount, setLastCount] = useState(0);
const [fireworks, setFireworks] = useState<Array<{ id: number; particles: Array<{ dx: number; dy: number; color: string }> }>>([]);
// Detect new rares
useEffect(() => {
if (recentRares.length > lastCount && lastCount > 0) {
const newRares = recentRares.slice(0, recentRares.length - lastCount);
for (const r of newRares) {
const key = ++notifKey;
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
// Trigger fireworks + sound
triggerFireworks();
try {
// Simple beep using Web Audio API
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.stop(ctx.currentTime + 0.5);
} catch { /* audio not available */ }
// Auto-remove after 6s
setTimeout(() => {
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
setTimeout(() => {
setActive(prev => prev.filter(n => n.key !== key));
}, 500);
}, 6000);
}
}
setLastCount(recentRares.length);
}, [recentRares.length]); // eslint-disable-line react-hooks/exhaustive-deps
const triggerFireworks = useCallback(() => {
const id = Date.now();
const colors = ['#FFD700', '#FF4444', '#FF8800', '#AA44FF', '#4488FF'];
const particles = Array.from({ length: 30 }, (_, i) => {
const angle = (Math.PI * 2 * i) / 30 + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
return {
dx: Math.cos(angle) * velocity,
dy: Math.sin(angle) * velocity - 50,
color: colors[Math.floor(Math.random() * colors.length)],
};
});
setFireworks(prev => [...prev, { id, particles }]);
setTimeout(() => setFireworks(prev => prev.filter(f => f.id !== id)), 2200);
}, []);
return (
<>
{/* Notification banners */}
<div className="ml-rare-notifications">
{active.map(n => (
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
<div className="ml-rare-notif-title">🎆 LEGENDARY RARE! 🎆</div>
<div className="ml-rare-notif-name">{n.rareName}</div>
<div className="ml-rare-notif-by">found by</div>
<div className="ml-rare-notif-char">{n.charName}</div>
</div>
))}
</div>
{/* Fireworks particles */}
<div className="ml-fireworks">
{fireworks.map(fw => (
<React.Fragment key={fw.id}>
{fw.particles.map((p, i) => (
<div
key={i}
className="ml-firework-particle"
style={{
left: '50%',
top: '30%',
backgroundColor: p.color,
'--dx': `${p.dx}px`,
'--dy': `${p.dy + 200}px`,
} as React.CSSProperties}
/>
))}
</React.Fragment>
))}
</div>
</>
);
};