Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 21 additions & 49 deletions src/app/components/gamification/GamificationSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { Volume2, VolumeX, Sparkles, SparklesIcon } from "lucide-react";
import { Volume2, VolumeX, Sparkles, Accessibility } from "lucide-react";
import { useGamificationStore } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { useSoundEffect } from "@/app/utils/soundManager";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";
import { useEffect } from "react";
Expand All @@ -11,45 +12,34 @@ interface GamificationSettingsProps {
}

export function GamificationSettings({ className }: GamificationSettingsProps) {
const soundEnabled = useGamificationStore((state) => state.soundEnabled);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const soundVolume = useGamificationStore((state) => state.soundVolume);
const toggleSound = useGamificationStore((state) => state.toggleSound);
const toggleAnimations = useGamificationStore((state) => state.toggleAnimations);
const setSoundVolume = useGamificationStore((state) => state.setSoundVolume);
const soundEnabled = useUIStore((state) => state.soundEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const setSoundEnabled = useUIStore((state) => state.setSoundEnabled);
const setReducedMotion = useUIStore((state) => state.setReducedMotion);

const sound = useSoundEffect();

// Sync sound manager with store
useEffect(() => {
sound.setEnabled(soundEnabled);
sound.setVolume(soundVolume);
}, [soundEnabled, soundVolume, sound]);
}, [soundEnabled, sound]);

const handleToggleSound = () => {
toggleSound();
if (!soundEnabled) {
const nextState = !soundEnabled;
setSoundEnabled(nextState);
if (nextState) {
// Play a test sound when enabling
setTimeout(() => sound.play("success"), 100);
}
};

const handleToggleAnimations = () => {
toggleAnimations();
if (soundEnabled) {
const handleToggleReducedMotion = () => {
setReducedMotion(!reducedMotion);
if (soundEnabled && reducedMotion) {
sound.play("click");
}
};

const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setSoundVolume(newVolume);
// Play a test sound at the new volume
if (soundEnabled) {
sound.play("xpGain");
}
};

return (
<Card className={className}>
<CardHeader>
Expand Down Expand Up @@ -88,48 +78,30 @@ export function GamificationSettings({ className }: GamificationSettingsProps) {
</button>
</div>

{/* Volume slider */}
{soundEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900 dark:text-zinc-100">
Volume: {Math.round(soundVolume * 100)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={soundVolume}
onChange={handleVolumeChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-zinc-700 accent-blue-600"
/>
</div>
)}

{/* Animations toggle */}
{/* Reduced Motion toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Sparkles
<Accessibility
className={`h-5 w-5 ${
animationsEnabled ? "text-purple-600 dark:text-purple-400" : "text-gray-400"
reducedMotion ? "text-purple-600 dark:text-purple-400" : "text-gray-400"
}`}
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-zinc-100">Animations</p>
<p className="text-sm font-medium text-gray-900 dark:text-zinc-100">Reduced Motion</p>
<p className="text-xs text-gray-600 dark:text-zinc-400">
Enable micro-animations and effects
Disable or tone down non-essential animations
</p>
</div>
</div>
<button
onClick={handleToggleAnimations}
onClick={handleToggleReducedMotion}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
animationsEnabled ? "bg-purple-600" : "bg-gray-300 dark:bg-zinc-700"
reducedMotion ? "bg-purple-600" : "bg-gray-300 dark:bg-zinc-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
animationsEnabled ? "translate-x-6" : "translate-x-1"
reducedMotion ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
Expand Down
4 changes: 3 additions & 1 deletion src/app/components/gamification/KingdomProgressWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { Crown, TrendingUp } from "lucide-react";
import { useGamificationStore, getNextLevelInfo } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { Card } from "../ui/Card";

interface KingdomProgressWidgetProps {
Expand All @@ -14,7 +15,8 @@ export function KingdomProgressWidget({ className, compact = false }: KingdomPro
const level = useGamificationStore((state) => state.level);
const xp = useGamificationStore((state) => state.xp);
const kingdomTitle = useGamificationStore((state) => state.kingdomTitle);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const animationsEnabled = !reducedMotion;

const { nextLevel, xpToNext, progress } = getNextLevelInfo(xp);
const isMaxLevel = nextLevel === level;
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/gamification/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Crown, Sparkles, Gift } from "lucide-react";
import { useGamificationStore } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { useSoundEffect } from "@/app/utils/soundManager";
import { Button } from "../ui/Button";
import { useModalFocusTrap } from "../../hooks/useModalFocusTrap";
Expand All @@ -12,8 +13,9 @@ export function LevelUpModal() {
const showModal = useGamificationStore((state) => state.showLevelUpModal);
const pendingLevelUp = useGamificationStore((state) => state.pendingLevelUp);
const dismissLevelUp = useGamificationStore((state) => state.dismissLevelUp);
const soundEnabled = useGamificationStore((state) => state.soundEnabled);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const soundEnabled = useUIStore((state) => state.soundEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const animationsEnabled = !reducedMotion;
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);

Expand Down
12 changes: 7 additions & 5 deletions src/app/components/gamification/XPGainAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { motion, AnimatePresence } from "framer-motion";
import { Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { useUIStore } from "@/app/stores/useUIStore";

interface XPGainAnimationProps {
amount: number;
Expand All @@ -18,6 +19,7 @@ export function XPGainAnimation({
position = "top",
}: XPGainAnimationProps) {
const [isVisible, setIsVisible] = useState(show);
const reducedMotion = useUIStore((state) => state.reducedMotion);

useEffect(() => {
if (show) {
Expand All @@ -40,16 +42,16 @@ export function XPGainAnimation({
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0.5, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -20 }}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5, y: 20 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8, y: -20 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={`fixed left-1/2 ${positionClasses[position]} z-50 -translate-x-1/2`}
>
<div className="flex items-center gap-2 rounded-full bg-gradient-to-r from-purple-600 to-blue-600 px-6 py-3 shadow-lg">
<motion.div
animate={{ rotate: [0, 360] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
animate={reducedMotion ? {} : { rotate: [0, 360] }}
transition={reducedMotion ? {} : { duration: 1, repeat: Infinity, ease: "linear" }}
>
<Sparkles size={20} className="text-yellow-300" />
</motion.div>
Expand Down
43 changes: 39 additions & 4 deletions src/app/stores/useUIStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";

// ─── Toast types ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -54,6 +54,9 @@ interface UIState {
/** True while a global loading spinner should be shown */
isGlobalLoading: boolean;
globalLoadingMessage: string | null;
/** Global settings for accessibility and comfort */
soundEnabled: boolean;
reducedMotion: boolean;
}

interface UIActions {
Expand Down Expand Up @@ -82,6 +85,11 @@ interface UIActions {

showGlobalLoading: (message?: string) => void;
hideGlobalLoading: () => void;

// ── Settings ──────────────────────────────────────────────────────────────

setSoundEnabled: (enabled: boolean) => void;
setReducedMotion: (reduced: boolean) => void;
}

export type UIStore = UIState & UIActions;
Expand All @@ -103,19 +111,29 @@ const defaultModals = Object.fromEntries(ALL_MODALS.map((id) => [id, { isOpen: f

// ─── Initial state ────────────────────────────────────────────────────────────

const getInitialReducedMotion = () => {
if (typeof window !== "undefined") {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
return false;
};

const initialState: UIState = {
modals: defaultModals,
toasts: [],
isGlobalLoading: false,
globalLoadingMessage: null,
soundEnabled: true,
reducedMotion: getInitialReducedMotion(),
};

// ─── Store ────────────────────────────────────────────────────────────────────

export const useUIStore = create<UIStore>()(
devtools(
(set) => ({
...initialState,
persist(
(set) => ({
...initialState,

// ── Modals ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -181,9 +199,24 @@ export const useUIStore = create<UIStore>()(

hideGlobalLoading: () =>
set({ isGlobalLoading: false, globalLoadingMessage: null }, false, "ui/hideGlobalLoading"),

// ── Settings ─────────────────────────────────────────────────────────────

setSoundEnabled: (enabled) =>
set({ soundEnabled: enabled }, false, "ui/setSoundEnabled"),

setReducedMotion: (reduced) =>
set({ reducedMotion: reduced }, false, "ui/setReducedMotion"),
}),
{ name: "UIStore" },
{
name: "ui-store",
partialize: (state) => ({
soundEnabled: state.soundEnabled,
reducedMotion: state.reducedMotion,
}),
},
),
{ name: "UIStore" },
);

// ─── Selectors ────────────────────────────────────────────────────────────────
Expand All @@ -192,3 +225,5 @@ export const selectModal = (id: ModalId) => (state: UIStore) => state.modals[id]
export const selectToasts = (state: UIStore) => state.toasts;
export const selectIsGlobalLoading = (state: UIStore) => state.isGlobalLoading;
export const selectGlobalLoadingMessage = (state: UIStore) => state.globalLoadingMessage;
export const selectSoundEnabled = (state: UIStore) => state.soundEnabled;
export const selectReducedMotion = (state: UIStore) => state.reducedMotion;
10 changes: 9 additions & 1 deletion src/app/utils/soundManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Handles loading, playing, and managing audio files.
*/

import { useUIStore } from "../stores/useUIStore";

export type SoundEffect =
| "levelUp"
| "achievement"
Expand All @@ -18,11 +20,17 @@ export type SoundEffect =
class SoundManager {
private sounds: Map<SoundEffect, HTMLAudioElement> = new Map();
private enabled: boolean = true;
private volume: number = 0.5;
private volume: number = 0.2;

constructor() {
if (typeof window !== "undefined") {
this.initializeSounds();

// Sync with store
this.enabled = useUIStore.getState().soundEnabled;
useUIStore.subscribe((state) => {
this.enabled = state.soundEnabled;
});
}
}

Expand Down