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>
109 lines
3.7 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|