From 3d9b1ecc774ead0c2c7ac08d43cdbed1dbda8e25 Mon Sep 17 00:00:00 2001 From: wangye Date: Sat, 25 Jul 2026 15:19:16 +0800 Subject: [PATCH 1/3] feat: add toast notification system with 4 variants and auto-dismiss - ToastContext for global state management - ToastContainer with framer-motion spring animations - 4 variants: success, error, warning, info - Auto-dismiss with configurable duration - Lucide icons for each variant - Dark theme styling matching SolFoundry design system Closes #825 --- frontend/src/components/ToastContainer.tsx | 89 ++++++++++++++++++++++ frontend/src/contexts/ToastContext.tsx | 81 ++++++++++++++++++++ frontend/src/main.tsx | 7 +- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ToastContainer.tsx create mode 100644 frontend/src/contexts/ToastContext.tsx diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx new file mode 100644 index 000000000..19c7a4128 --- /dev/null +++ b/frontend/src/components/ToastContainer.tsx @@ -0,0 +1,89 @@ +/** + * ToastContainer — Renders active toasts with framer-motion animations. + * Fixed to top-right corner, stacked vertically. + */ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'; +import { useToast, type ToastType } from '../contexts/ToastContext'; + +const ICON_MAP: Record = { + success: , + error: , + warning: , + info: , +}; + +const BORDER_MAP: Record = { + success: 'border-l-status-success', + error: 'border-l-status-error', + warning: 'border-l-status-warning', + info: 'border-l-status-info', +}; + +function ToastItem({ + id, + type, + title, + message, + onDismiss, +}: { + id: string; + type: ToastType; + title: string; + message?: string; + onDismiss: () => void; +}) { + return ( + + {ICON_MAP[type]} +
+

{title}

+ {message && ( +

{message}

+ )} +
+ +
+ ); +} + +export default function ToastContainer() { + const { toasts, removeToast } = useToast(); + + return ( +
+ + {toasts.map((t) => ( +
+ removeToast(t.id)} + /> +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..6181ecc2a --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,81 @@ +/** + * ToastContext — Global toast notification system. + * Supports 4 variants (success, error, warning, info) with auto-dismiss. + * Powered by framer-motion for smooth enter/exit animations. + */ +import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + type: ToastType; + title: string; + message?: string; + duration?: number; +} + +interface ToastContextValue { + toasts: Toast[]; + addToast: (toast: Omit) => string; + removeToast: (id: string) => void; + clearToasts: () => void; +} + +const ToastContext = createContext(null); + +let toastCounter = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + }, []); + + const addToast = useCallback( + (toast: Omit): string => { + const id = `toast-${++toastCounter}-${Date.now()}`; + const newToast: Toast = { ...toast, id }; + const duration = toast.duration ?? 5000; + + setToasts((prev) => [...prev, newToast]); + + if (duration > 0) { + const timer = setTimeout(() => { + removeToast(id); + }, duration); + timersRef.current.set(id, timer); + } + + return id; + }, + [removeToast] + ); + + const clearToasts = useCallback(() => { + timersRef.current.forEach((timer) => clearTimeout(timer)); + timersRef.current.clear(); + setToasts([]); + }, []); + + return ( + + {children} + + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToast must be used within a ToastProvider'); + } + return ctx; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b20036806..93e3510b9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from './contexts/AuthContext'; +import { ToastProvider } from './contexts/ToastContext'; import { queryClient } from './services/queryClient'; import App from './App'; +import ToastContainer from './components/ToastContainer'; import './index.css'; const root = document.getElementById('root'); @@ -15,7 +17,10 @@ createRoot(root).render( - + + + + From 634ef17bd0dcf9a4cb8b527cbfacdcb4eceaa8cc Mon Sep 17 00:00:00 2001 From: waterWang Date: Wed, 5 Aug 2026 04:24:27 +0800 Subject: [PATCH 2/3] feat: add toast notification system with 4 variants and auto-dismiss (Closes #825) Add a reusable toast notification system with: - Success, error, warning, info variants - Auto-dismiss after 5 seconds - Slide-in animation from top-right (framer-motion) - Stack multiple toasts - Manual close button - Accessible (role=alert) --- frontend/src/components/ToastContainer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx index 19c7a4128..fb4301d68 100644 --- a/frontend/src/components/ToastContainer.tsx +++ b/frontend/src/components/ToastContainer.tsx @@ -41,6 +41,7 @@ function ToastItem({ animate={{ opacity: 1, x: 0, scale: 1 }} exit={{ opacity: 0, x: 80, scale: 0.95, transition: { duration: 0.2 } }} transition={{ type: 'spring', stiffness: 400, damping: 30, mass: 0.8 }} + role="alert" className={` flex items-start gap-3 p-4 pr-3 w-80 rounded-lg shadow-lg From f6c41480cf8e532a388f41c52cfdb0c407309944 Mon Sep 17 00:00:00 2001 From: waterWang Date: Wed, 5 Aug 2026 06:07:05 +0800 Subject: [PATCH 3/3] feat: add interactive 3D WebGL forge visualization using Three.js (Closes #865) [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- frontend/package.json | 4 +- frontend/src/components/home/ForgeScene.tsx | 346 ++++++++++++++++++++ frontend/src/pages/HomePage.tsx | 2 + 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/home/ForgeScene.tsx diff --git a/frontend/package.json b/frontend/package.json index f3f83792a..80e61bcdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,8 @@ "recharts": "^3.8.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "three": "^0.185.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.5", @@ -33,6 +34,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.0", "jsdom": "^25.0.0", + "@types/three": "^0.185.1", "typescript": "^5.4.5", "vite": "^6.0.0", "vitest": "^3.0.0" diff --git a/frontend/src/components/home/ForgeScene.tsx b/frontend/src/components/home/ForgeScene.tsx new file mode 100644 index 000000000..c3e0d27f8 --- /dev/null +++ b/frontend/src/components/home/ForgeScene.tsx @@ -0,0 +1,346 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import * as THREE from 'three'; + +// ─── constants ─────────────────────────────────────────────────────────────── +const PARTICLE_COUNT = 150; +const SPARK_COUNT = 40; +const BOUNTY_COUNT = 6; + +interface BountyItem { + mesh: THREE.Mesh; + baseY: number; + speed: number; + phase: number; + color: THREE.Color; +} + +interface Spark { + mesh: THREE.Mesh; + velocity: THREE.Vector3; + life: number; + maxLife: number; +} + +// ─── component ──────────────────────────────────────────────────────────────── +export function ForgeScene() { + const containerRef = useRef(null); + const sceneRef = useRef<{ + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + renderer: THREE.WebGLRenderer; + particles: THREE.Points; + bounties: BountyItem[]; + sparks: Spark[]; + clock: THREE.Clock; + rafId: number; + } | null>(null); + + const initScene = useCallback((container: HTMLDivElement) => { + const w = container.clientWidth; + const h = container.clientHeight; + + // ── renderer ────────────────────────────────────────────────────────── + const renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + powerPreference: 'high-performance', + }); + renderer.setSize(w, h); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.2; + container.appendChild(renderer.domElement); + + // ── scene ───────────────────────────────────────────────────────────── + const scene = new THREE.Scene(); + + // ── camera ──────────────────────────────────────────────────────────── + const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50); + camera.position.set(4, 3, 6); + camera.lookAt(0, 0.5, 0); + + // ── lights ──────────────────────────────────────────────────────────── + const ambient = new THREE.AmbientLight(0x222244, 0.4); + scene.add(ambient); + + const forgeLight = new THREE.PointLight(0xff6600, 3, 8); + forgeLight.position.set(0, 0.8, 0); + scene.add(forgeLight); + + const rimLight = new THREE.DirectionalLight(0x4488ff, 0.6); + rimLight.position.set(-2, 4, 3); + scene.add(rimLight); + + const fillLight = new THREE.DirectionalLight(0xff8844, 0.3); + fillLight.position.set(3, 1, -2); + scene.add(fillLight); + + // ── forge platform ──────────────────────────────────────────────────── + const platformGeo = new THREE.CylinderGeometry(1.8, 2.2, 0.15, 32); + const platformMat = new THREE.MeshStandardMaterial({ + color: 0x1a1a2e, + metalness: 0.9, + roughness: 0.4, + }); + const platform = new THREE.Mesh(platformGeo, platformMat); + platform.position.y = -0.1; + scene.add(platform); + + // ── anvil / forge core ──────────────────────────────────────────────── + const forgeGeo = new THREE.CylinderGeometry(0.6, 0.9, 0.8, 24); + const forgeMat = new THREE.MeshStandardMaterial({ + color: 0x2a1a0a, + metalness: 0.8, + roughness: 0.3, + emissive: 0xff4400, + emissiveIntensity: 0.15, + }); + const forge = new THREE.Mesh(forgeGeo, forgeMat); + forge.position.y = 0.4; + scene.add(forge); + + // ── forge glow ring ─────────────────────────────────────────────────── + const glowGeo = new THREE.TorusGeometry(0.7, 0.08, 16, 32); + const glowMat = new THREE.MeshBasicMaterial({ + color: 0xff6600, + transparent: true, + opacity: 0.4, + }); + const glowRing = new THREE.Mesh(glowGeo, glowMat); + glowRing.position.y = 0.45; + glowRing.rotation.x = Math.PI / 2; + scene.add(glowRing); + + // ── inner glow ──────────────────────────────────────────────────────── + const innerGlowGeo = new THREE.SphereGeometry(0.35, 16, 16); + const innerGlowMat = new THREE.MeshBasicMaterial({ + color: 0xff8800, + transparent: true, + opacity: 0.25, + }); + const innerGlow = new THREE.Mesh(innerGlowGeo, innerGlowMat); + innerGlow.position.y = 0.45; + scene.add(innerGlow); + + // ── particle system (embers) ────────────────────────────────────────── + const particleGeo = new THREE.BufferGeometry(); + const positions = new Float32Array(PARTICLE_COUNT * 3); + const colors = new Float32Array(PARTICLE_COUNT * 3); + const sizes = new Float32Array(PARTICLE_COUNT); + + for (let i = 0; i < PARTICLE_COUNT; i++) { + const theta = Math.random() * Math.PI * 2; + const radius = 0.3 + Math.random() * 1.5; + positions[i * 3] = Math.cos(theta) * radius; + positions[i * 3 + 1] = 0.4 + Math.random() * 2.5; + positions[i * 3 + 2] = Math.sin(theta) * radius; + + const t = Math.random(); + colors[i * 3] = 1; + colors[i * 3 + 1] = 0.4 + t * 0.5; + colors[i * 3 + 2] = t * 0.3; + + sizes[i] = 0.03 + Math.random() * 0.06; + } + + particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + particleGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + particleGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const particleMat = new THREE.PointsMaterial({ + size: 0.05, + vertexColors: true, + transparent: true, + opacity: 0.7, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const particles = new THREE.Points(particleGeo, particleMat); + scene.add(particles); + + // ── sparks (shooting particles) ─────────────────────────────────────── + const sparksArr: Spark[] = []; + for (let i = 0; i < SPARK_COUNT; i++) { + const sparkGeo = new THREE.SphereGeometry(0.02, 4, 4); + const sparkMat = new THREE.MeshBasicMaterial({ + color: 0xffaa44, + transparent: true, + opacity: 0.9, + }); + const sparkMesh = new THREE.Mesh(sparkGeo, sparkMat); + sparkMesh.position.set(0, 0.5, 0); + scene.add(sparkMesh); + + sparksArr.push({ + mesh: sparkMesh, + velocity: new THREE.Vector3( + (Math.random() - 0.5) * 2, + 1.5 + Math.random() * 3, + (Math.random() - 0.5) * 2, + ), + life: Math.random(), + maxLife: 0.5 + Math.random() * 1.5, + }); + } + + // ── bounty items (floating cubes) ───────────────────────────────────── + const bountiesArr: BountyItem[] = []; + const bountyColors = [0x00e676, 0x4488ff, 0xff6600, 0xe040fb, 0xffd600, 0x00bcd4]; + for (let i = 0; i < BOUNTY_COUNT; i++) { + const size = 0.08 + Math.random() * 0.1; + const bountyGeo = new THREE.BoxGeometry(size, size, size); + const color = new THREE.Color(bountyColors[i % bountyColors.length]); + const bountyMat = new THREE.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.6, + metalness: 0.5, + roughness: 0.2, + }); + const bountyMesh = new THREE.Mesh(bountyGeo, bountyMat); + + const angle = (i / BOUNTY_COUNT) * Math.PI * 2; + const radius = 0.5 + Math.random() * 0.5; + bountyMesh.position.set( + Math.cos(angle) * radius, + 0.5 + Math.random() * 0.3, + Math.sin(angle) * radius, + ); + + scene.add(bountyMesh); + bountiesArr.push({ + mesh: bountyMesh, + baseY: bountyMesh.position.y, + speed: 0.3 + Math.random() * 0.5, + phase: Math.random() * Math.PI * 2, + color, + }); + } + + // ── store refs ──────────────────────────────────────────────────────── + const clock = new THREE.Clock(); + + const state = { + scene, + camera, + renderer, + particles, + bounties: bountiesArr, + sparks: sparksArr, + clock, + rafId: 0, + }; + sceneRef.current = state; + + // ── animate ─────────────────────────────────────────────────────────── + function animate() { + const delta = clock.getDelta(); + const elapsed = clock.getElapsedTime(); + + // Rotate particles + particles.rotation.y += delta * 0.15; + + // Animate individual particle positions (drift upward) + const pos = particles.geometry.attributes.position.array as Float32Array; + for (let i = 0; i < PARTICLE_COUNT; i++) { + pos[i * 3 + 1] += delta * (0.1 + Math.sin(elapsed + i) * 0.05); + if (pos[i * 3 + 1] > 3) { + pos[i * 3 + 1] = 0.4; + const theta = Math.random() * Math.PI * 2; + const radius = 0.3 + Math.random() * 1.5; + pos[i * 3] = Math.cos(theta) * radius; + pos[i * 3 + 2] = Math.sin(theta) * radius; + } + } + particles.geometry.attributes.position.needsUpdate = true; + + // Animate forge glow + const pulse = 0.6 + Math.sin(elapsed * 2) * 0.4; + forgeLight.intensity = 2 + pulse * 2; + innerGlowMat.opacity = 0.15 + Math.sin(elapsed * 2.5) * 0.12; + glowRing.scale.setScalar(1 + Math.sin(elapsed * 1.5) * 0.05); + + // Animate bounties (float + rotate) + for (const b of bountiesArr) { + b.mesh.position.y = b.baseY + Math.sin(elapsed * b.speed + b.phase) * 0.15; + b.mesh.rotation.x += delta * 0.5; + b.mesh.rotation.y += delta * 0.8; + } + + // Animate sparks + for (const s of sparksArr) { + s.life -= delta; + if (s.life <= 0) { + s.mesh.position.set(0, 0.5, 0); + s.velocity.set( + (Math.random() - 0.5) * 2.5, + 1.5 + Math.random() * 3.5, + (Math.random() - 0.5) * 2.5, + ); + s.life = s.maxLife; + s.mesh.scale.setScalar(1); + (s.mesh.material as THREE.MeshBasicMaterial).opacity = 0.9; + } else { + s.mesh.position.x += s.velocity.x * delta; + s.mesh.position.y += s.velocity.y * delta; + s.mesh.position.z += s.velocity.z * delta; + + s.velocity.y -= delta * 2.5; // gravity + + const lifeRatio = s.life / s.maxLife; + s.mesh.scale.setScalar(lifeRatio); + (s.mesh.material as THREE.MeshBasicMaterial).opacity = lifeRatio * 0.9; + } + } + + // Gentle camera orbit + camera.position.x = 4 * Math.cos(elapsed * 0.08); + camera.position.z = 4 * Math.sin(elapsed * 0.08); + camera.lookAt(0, 0.5, 0); + + renderer.render(scene, camera); + state.rafId = requestAnimationFrame(animate); + } + + animate(); + + return state; + }, []); + + // ── mount / unmount ─────────────────────────────────────────────────────── + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const state = initScene(container); + + const handleResize = () => { + if (!container || !sceneRef.current) return; + const w = container.clientWidth; + const h = container.clientHeight; + sceneRef.current.camera.aspect = w / h; + sceneRef.current.camera.updateProjectionMatrix(); + sceneRef.current.renderer.setSize(w, h); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + cancelAnimationFrame(state.rafId); + if (state.renderer.domElement.parentElement) { + state.renderer.domElement.parentElement.removeChild(state.renderer.domElement); + } + state.renderer.dispose(); + }; + }, [initScene]); + + return ( +