feat: Implement and refine a Toast notification system, and replace the copy pack toast with an animated button.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
85
src/client/src/components/Toast.tsx
Normal file
85
src/client/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user