feat: Implement and refine a Toast notification system, and replace the copy pack toast with an animated button.

This commit is contained in:
2025-12-17 02:22:53 +01:00
parent b0dc734859
commit 3194be382f
8 changed files with 227 additions and 57 deletions

View File

@@ -67,3 +67,7 @@
- [Change Default Filter Flags](./devlog/2025-12-17-025500_change_default_flags.md): Completed. Updated client and server defaults for "Ignore Basic Lands", "Ignore Commander Sets", and "Ignore Tokens" to be unchecked (false).
- [Sidebar Max Width](./devlog/2025-12-17-031000_sidebar_max_width.md): Completed. Constrained the left sidebar in Cube Manager to a maximum width of 400px on large screens to improve layout balance.
- [Strict Pack Generation Logic](./devlog/2025-12-17-030600_fix_strict_pack_generation.md): Succeeded. Enforced strict 14-card (Standard) and 13-card (Peasant) limits, removing fallback logic to prevent invalid pack generation.
- [Toast Notification Replacement](./devlog/2025-12-17-022000_replace_alert_with_toast.md): Completed. Replaced invasive alerts with a custom Toast notification system for smoother UX.
- [Enhanced Toast Visibility](./devlog/2025-12-17-023000_enhanced_toast_visibility.md): Completed. Updated toasts to be top-center, animated, and highly visible with premium styling.
- [Unified Toast Design](./devlog/2025-12-17-023500_unified_toast_design.md): Completed. Refined toast aesthetics to match the global dark/slate theme with subtle colored accents.
- [Animated Copy Button](./devlog/2025-12-17-024000_animated_copy_button.md): Completed. Replaced copy toast with an in-place animated tick button for immediate feedback.

View File

@@ -0,0 +1,16 @@
### Replaced Alert with Toast Notification
**Status**: Completed
**Date**: 2025-12-17
**Description**
Replaced the invasive `alert()` on the "Copy Pack" button with a non-intrusive Toast notification.
**Changes**
1. Created `src/client/src/components/Toast.tsx` with a `ToastProvider` and `useToast` hook.
2. Wrapped `App.tsx` with `ToastProvider`.
3. Updated `PackCard.tsx` to use `showToast` instead of `alert`.
**Next Steps**
- Consider replacing other alerts in `CubeManager` with Toasts for consistency.

View File

@@ -0,0 +1,21 @@
### Enhanced Toast Visibility
**Status**: Completed
**Date**: 2025-12-17
**Description**
Updated the Toast notification to be more prominent and centrally located, as per user feedback.
**Changes**
1. **Position**: Moved from bottom-right to top-center (`top-6 left-1/2 -translate-x-1/2`).
2. **Animation**: Changed to `slide-in-from-top-full` with a slight zoom-in effect.
3. **Styling**:
- Increased padding (`px-6 py-4`).
- Increased border width (`border-2`) and brightness.
- Added stronger shadows (`shadow-2xl`).
- Increased contrast for text and background.
- Increased font weight to `bold`.
**Effect**
Notifications are now impossible to miss, appearing top-center with a premium, game-like alert style.

View File

@@ -0,0 +1,17 @@
### Unified Toast Design
**Status**: Completed
**Date**: 2025-12-17
**Description**
Refined the Toast notification design to align perfectly with the "Dark Gaming Aesthetic" of the platform.
**Changes**
1. **Consistent Palette**: Switched to `bg-slate-800` (cards) with `border-slate-700` equivalents, using colored accents only for borders and icons.
2. **Icon Enclosure**: Icons are now housed in a circular, semi-transparent colored background (`bg-emerald-500/10`) for a polished look.
3. **Typography**: Reverted to standard font weights used in other UI cards (`font-medium`) for better readability and consistency.
4. **Shadows**: Tuned shadows to be deep but subtle (`shadow-2xl shadow-emerald-900/20`), matching the ambient lighting of the app.
**Effect**
The Toast now feels like a native part of the UI rather than a generic alert overlay.

View File

@@ -0,0 +1,20 @@
### Animated Copy Button
**Status**: Completed
**Date**: 2025-12-17
**Description**
Replaced the toast notification for the copy action with a self-contained, animated button state.
**Changes**
1. **Removed Toast Usage**: Detached `useToast` from `PackCard.tsx`.
2. **Local State**: Implemented `copied` state in `PackCard`.
3. **UI Feedback**:
- Button transitions from "Copy" (slate) to "Check" (emerald/green) on click.
- Added `animate-in zoom-in spin-in-12` for a satisfying "tick" animation.
- Button background and border glow green to confirm success.
- Auto-reverts after 2 seconds.
**Effect**
Provides immediate, localized feedback for the copy action without clogging the global UI with notifications.

View File

@@ -5,6 +5,7 @@ import { TournamentManager } from './modules/tournament/TournamentManager';
import { LobbyManager } from './modules/lobby/LobbyManager';
import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService';
import { ToastProvider } from './components/Toast';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
@@ -35,58 +36,60 @@ export const App: React.FC = () => {
}, [generatedPacks]);
return (
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-3">
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
<ToastProvider>
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-3">
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
</div>
</div>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button
onClick={() => setActiveTab('draft')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
</button>
<button
onClick={() => setActiveTab('lobby')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
</button>
<button
onClick={() => setActiveTab('tester')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
</button>
</div>
</div>
</header>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button
onClick={() => setActiveTab('draft')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
</button>
<button
onClick={() => setActiveTab('lobby')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
</button>
<button
onClick={() => setActiveTab('tester')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
</button>
</div>
</div>
</header>
<main className="flex-1 overflow-hidden relative">
{activeTab === 'draft' && (
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
onGoToLobby={() => setActiveTab('lobby')}
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>
<main className="flex-1 overflow-hidden relative">
{activeTab === 'draft' && (
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
onGoToLobby={() => setActiveTab('lobby')}
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>
</ToastProvider>
);
};

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { DraftCard, Pack } from '../services/PackGeneratorService';
import { Copy } from 'lucide-react';
import { Copy, Check } from 'lucide-react';
import { StackView } from './StackView';
import { CardHoverWrapper, FoilOverlay } from './CardPreview';
interface PackCardProps {
pack: Pack;
@@ -9,9 +10,6 @@ interface PackCardProps {
cardWidth?: number;
}
import { CardHoverWrapper, FoilOverlay } from './CardPreview';
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
const isFoil = (card: DraftCard) => card.finish === 'foil';
@@ -43,6 +41,7 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
};
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth = 150 }) => {
const [copied, setCopied] = React.useState(false);
const mythics = pack.cards.filter(c => c.rarity === 'mythic');
const rares = pack.cards.filter(c => c.rarity === 'rare');
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
@@ -53,7 +52,8 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
const copyPackToClipboard = () => {
const text = pack.cards.map(c => c.name).join('\n');
navigator.clipboard.writeText(text);
alert(`Pack list ${pack.id} copied!`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
@@ -64,8 +64,12 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
<h3 className="font-bold text-purple-400 text-sm md:text-base">Pack #{pack.id}</h3>
<span className="text-xs text-slate-500 font-mono">{pack.setName}</span>
</div>
<button onClick={copyPackToClipboard} className="text-slate-400 hover:text-white p-1 rounded hover:bg-slate-700 transition-colors flex items-center gap-2 text-xs">
<Copy className="w-4 h-4" />
<button
onClick={copyPackToClipboard}
className={`p-1.5 rounded transition-all duration-300 flex items-center gap-2 text-xs border ${copied ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/50' : 'text-slate-400 border-transparent hover:text-white hover:bg-slate-700'}`}
title="Copy to clipboard"
>
{copied ? <Check className="w-4 h-4 scale-110 animate-in zoom-in spin-in-12 duration-300" /> : <Copy className="w-4 h-4" />}
</button>
</div>

View File

@@ -0,0 +1,85 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { X, Check, AlertCircle, Info } from 'lucide-react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, message, type }]);
// Auto remove after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] flex flex-col gap-3 pointer-events-none w-full max-w-sm px-4">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
pointer-events-auto
flex items-center gap-4 px-4 py-3 rounded-xl border shadow-2xl
animate-in slide-in-from-top-full fade-in zoom-in-95 duration-300
bg-slate-800 text-white
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
'border-blue-500/50 shadow-blue-900/20'}
`}
>
<div className={`p-2 rounded-full shrink-0 ${toast.type === 'success' ? 'bg-emerald-500/10 text-emerald-400' :
toast.type === 'error' ? 'bg-red-500/10 text-red-400' :
'bg-blue-500/10 text-blue-400'
}`}>
{toast.type === 'success' && <Check className="w-5 h-5" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5" />}
{toast.type === 'info' && <Info className="w-5 h-5" />}
</div>
<div className="flex-1 text-sm font-medium">
{toast.message}
</div>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-slate-700 rounded transition-colors text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};