Этот коммит содержится в:
Constantin Rusu 2025-02-01 16:25:14 +00:00
родитель 444400bddf
Коммит 55599a2da2
20 изменённых файлов: 357 добавлений и 551 удалений

Просмотреть файл

@ -1,4 +1,3 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -10,7 +9,6 @@ const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>

Просмотреть файл

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from 'react';
import { Card, CardContent } from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { getMonthConfig } from '@/utils/months';
export enum TransitionStyle {
FADE = "fade",
@ -11,21 +12,20 @@ export enum TransitionStyle {
}
interface MonthTransitionProps {
monthIndex: number;
stage: number;
onComplete: () => void;
style: TransitionStyle;
style?: TransitionStyle;
}
// Helper function to translate month name
const useTranslatedMonth = (monthIndex: number) => {
const useTranslatedMonth = (stage: number) => {
const { t } = useTranslation();
const monthKeys = ['january', 'march', 'may', 'july', 'september', 'november', 'december', 'alert', 'exposé'];
return t(`months.${monthKeys[monthIndex]}`);
const monthConfig = getMonthConfig(stage);
return monthConfig ? t(monthConfig.translationKey) : '';
};
// Create separate components for each style
const FadeTransition = ({ monthIndex }: { monthIndex: number }) => {
const translatedMonth = useTranslatedMonth(monthIndex);
const FadeTransition = ({ stage }: { stage: number }) => {
const translatedMonth = useTranslatedMonth(stage);
return (
<Card className="bg-transparent border-none shadow-none">
<CardContent className="flex items-center justify-center px-4">
@ -37,8 +37,8 @@ const FadeTransition = ({ monthIndex }: { monthIndex: number }) => {
);
};
const TypewriterTransition = ({ monthIndex }: { monthIndex: number }) => {
const translatedMonth = useTranslatedMonth(monthIndex);
const TypewriterTransition = ({ stage }: { stage: number }) => {
const translatedMonth = useTranslatedMonth(stage);
return (
<div className="relative px-4">
<div className="overflow-hidden whitespace-normal md:whitespace-nowrap border-r-4 border-yellow-500 pr-1 text-4xl md:text-6xl font-bold text-yellow-500 animate-typewriter animate-cursor-blink max-w-[90vw] break-words">
@ -48,8 +48,8 @@ const TypewriterTransition = ({ monthIndex }: { monthIndex: number }) => {
);
};
const SplitScreenTransition = ({ monthIndex }: { monthIndex: number }) => {
const translatedMonth = useTranslatedMonth(monthIndex);
const SplitScreenTransition = ({ stage }: { stage: number }) => {
const translatedMonth = useTranslatedMonth(stage);
return (
<>
<div className="absolute inset-0 flex">
@ -63,8 +63,8 @@ const SplitScreenTransition = ({ monthIndex }: { monthIndex: number }) => {
);
};
const MatrixTransition = ({ monthIndex }: { monthIndex: number }) => {
const translatedMonth = useTranslatedMonth(monthIndex);
const MatrixTransition = ({ stage }: { stage: number }) => {
const translatedMonth = useTranslatedMonth(stage);
return (
<>
<div className="absolute inset-0 overflow-hidden">
@ -90,8 +90,8 @@ const MatrixTransition = ({ monthIndex }: { monthIndex: number }) => {
);
};
const NumberCycleTransition = ({ monthIndex }: { monthIndex: number }) => {
const translatedMonth = useTranslatedMonth(monthIndex);
const NumberCycleTransition = ({ stage }: { stage: number }) => {
const translatedMonth = useTranslatedMonth(stage);
const [displayText, setDisplayText] = useState(
Array(translatedMonth.length).fill('0').join('')
);
@ -152,7 +152,7 @@ const NumberCycleTransition = ({ monthIndex }: { monthIndex: number }) => {
);
};
export const MonthTransition = ({ monthIndex, onComplete, style }: MonthTransitionProps) => {
export const MonthTransition = ({ stage, onComplete, style }: MonthTransitionProps) => {
useEffect(() => {
const timer = setTimeout(onComplete, 3500);
return () => clearTimeout(timer);
@ -161,17 +161,17 @@ export const MonthTransition = ({ monthIndex, onComplete, style }: MonthTransiti
const renderTransition = () => {
switch (style) {
case TransitionStyle.FADE:
return <FadeTransition monthIndex={monthIndex} />;
return <FadeTransition stage={stage} />;
case TransitionStyle.TYPEWRITER:
return <TypewriterTransition monthIndex={monthIndex} />;
return <TypewriterTransition stage={stage} />;
case TransitionStyle.SPLIT_SCREEN:
return <SplitScreenTransition monthIndex={monthIndex} />;
return <SplitScreenTransition stage={stage} />;
case TransitionStyle.MATRIX:
return <MatrixTransition monthIndex={monthIndex} />;
return <MatrixTransition stage={stage} />;
case TransitionStyle.NUMBER_CYCLE:
return <NumberCycleTransition monthIndex={monthIndex} />;
return <NumberCycleTransition stage={stage} />;
default:
return <FadeTransition monthIndex={monthIndex} />;
return <FadeTransition stage={stage} />;
}
};

Просмотреть файл

@ -3,7 +3,8 @@ import { Button } from "@/components/ui/button";
import { PlayIcon, PauseIcon } from "@heroicons/react/24/outline";
import { useTranslation } from "react-i18next";
import { playBriefing } from "@/utils/audio";
import { toast } from "@/components/ui/use-toast";
import { toast } from "sonner";
import { getMonthConfig } from "@/utils/months";
interface BriefingAudioProps {
stage: string;
@ -18,17 +19,22 @@ export const BriefingAudio = ({ stage, audioRef, className = "" }: BriefingAudio
const getAudioFileName = (stage: string) => {
const currentLanguage = i18n.language;
const monthKeys = ['january', 'march', 'may', 'july', 'september', 'november', 'december', 'alert', 'expose'];
console.log('BriefingAudio - Stage received:', stage);
// Handle special stages
if (stage === "INTRO") {
return `intro-${currentLanguage}.mp3`;
}
// For all other stages (including ALERT), use the month-based naming
const monthIndex = parseInt(stage);
const monthKey = monthKeys[monthIndex];
return `${monthKey}-${currentLanguage}.mp3`;
const monthConfig = getMonthConfig(stage);
console.log('BriefingAudio - Selected monthConfig:', monthConfig);
if (!monthConfig?.audio?.briefing) {
throw new Error(`No audio briefing configured for stage ${stage}`);
}
return monthConfig.audio.briefing;
};
const handlePlayPause = async () => {
@ -56,10 +62,8 @@ export const BriefingAudio = ({ stage, audioRef, className = "" }: BriefingAudio
setIsPlaying(true);
} catch (error) {
console.error('Audio error:', error);
toast({
title: "Audio Error",
description: `Failed to play briefing: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
toast.error("Audio Error", {
description: `Failed to play briefing: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
};

Просмотреть файл

@ -81,10 +81,20 @@ export const DossierPanel = ({ entries, choices = [] }: DossierPanelProps) => {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="space-y-4 relative bg-gray-800/30 p-4 sm:p-6 rounded-md border border-gray-700"
className={cn(
"space-y-4 relative bg-gray-800/30 p-4 sm:p-6 rounded-md border",
entry.dateKey.toLowerCase().includes('alert') || entry.dateKey.toLowerCase().includes('expose')
? "border-red-500/50"
: "border-gray-700"
)}
>
<div>
<h3 className="text-yellow-500 font-semibold flex items-center gap-3">
<h3 className={cn(
"font-semibold flex items-center gap-3",
entry.dateKey.toLowerCase().includes('alert') || entry.dateKey.toLowerCase().includes('expose')
? "text-red-500"
: "text-yellow-500"
)}>
<span className="text-xs text-gray-400 font-mono tracking-wider">{t(entry.dateKey)}</span>
<Separator className="w-4 bg-gray-700" orientation="horizontal" />
<TypewriterText text={t(entry.titleKey)} />

Просмотреть файл

@ -51,7 +51,8 @@ export const ExpertMemo: React.FC<ExpertMemoProps> = ({ from, subject, children,
<p key={index}>{paragraph}</p>
));
}
return content;
// If it's already a React node (like a div), return it as is
return <div className="prose prose-invert">{content}</div>;
};
return (

Просмотреть файл

@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { toast } from "sonner";
import { useTranslation } from 'react-i18next';
import { playRecordingSound } from "@/utils/audio";
@ -12,7 +12,6 @@ interface IntroAudioProps {
export const IntroAudio = ({ className }: IntroAudioProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { toast } = useToast();
const { t, i18n } = useTranslation();
useEffect(() => {
@ -46,10 +45,8 @@ export const IntroAudio = ({ className }: IntroAudioProps) => {
if (playPromise !== undefined) {
playPromise.catch(error => {
console.error('Playback failed:', error);
toast({
title: "Playback Error",
description: "Unable to play audio briefing",
variant: "destructive"
toast.error("Playback Error", {
description: "Unable to play audio briefing"
});
});
}
@ -57,10 +54,8 @@ export const IntroAudio = ({ className }: IntroAudioProps) => {
setIsPlaying(!isPlaying);
} catch (error) {
console.error('Audio error:', error);
toast({
title: "Audio Error",
description: "Audio briefing unavailable",
variant: "destructive"
toast.error("Audio Error", {
description: "Audio briefing unavailable"
});
}
};

Просмотреть файл

@ -5,9 +5,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { cn } from "@/lib/utils";
interface IntroDialogProps {
onStartAudio?: () => void;
@ -16,6 +17,31 @@ interface IntroDialogProps {
export const IntroDialog = ({ onStartAudio }: IntroDialogProps) => {
const [open, setOpen] = useState(true);
const { t } = useTranslation();
const [showGradient, setShowGradient] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const checkScroll = useCallback(() => {
const element = contentRef.current;
if (element) {
const hasOverflow = element.scrollHeight > element.clientHeight;
const isAtBottom = Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) < 1;
setShowGradient(hasOverflow && !isAtBottom);
}
}, []);
useEffect(() => {
const element = contentRef.current;
if (element) {
checkScroll();
element.addEventListener('scroll', checkScroll);
window.addEventListener('resize', checkScroll);
return () => {
element.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}
}, [checkScroll]);
const handleBeginSimulation = () => {
setOpen(false);
@ -23,57 +49,62 @@ export const IntroDialog = ({ onStartAudio }: IntroDialogProps) => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open}>
<DialogContent
className="bg-black text-white border-gray-700 max-w-2xl max-h-[85vh] overflow-y-auto"
ref={contentRef}
className="[&>button]:hidden bg-black text-white border-gray-700 max-w-2xl max-h-[85vh] overflow-y-auto space-y-6 p-6 pb-[30px] relative text-center fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]"
>
<div className="relative flex-1 min-h-0">
<div className="space-y-6">
<DialogHeader>
<DialogTitle className="text-yellow-500 text-2xl mb-6">
{t('intro.title')}
</DialogTitle>
<DialogHeader className="space-y-6">
<DialogTitle className="text-yellow-500 text-2xl">
{t('intro.title')}
</DialogTitle>
<div className="space-y-6 text-gray-200">
<div className="flex items-center gap-4">
<div className="text-4xl">🎯</div>
<p className="text-lg font-medium">
{t('intro.mission')}
</p>
</div>
<p className="text-base">
{t('intro.explanation')}
</p>
<p className="text-base">
{t('intro.howToPlay.description')}
</p>
<p className="text-yellow-500 text-sm">
{t('intro.reminder')}
</p>
</div>
</DialogHeader>
<div className="flex flex-col items-center gap-6 mt-8">
<div className="flex items-center gap-2 self-start">
<LanguageSwitcher />
<span className="text-xs text-gray-400">
{t('languageSwitcher.hint')}
</span>
</div>
<Button
onClick={handleBeginSimulation}
className="bg-yellow-500 hover:bg-yellow-600 text-black font-semibold w-full py-6 text-lg"
>
{t('buttons.beginSimulation')}
</Button>
<div className="space-y-6 text-gray-200">
<div className="flex items-center justify-center gap-4">
<div className="text-4xl">🎯</div>
<p className="text-lg font-medium">
{t('intro.mission')}
</p>
</div>
<p className="text-base leading-relaxed drop-shadow-lg">
{t('intro.explanation')}
</p>
<p className="text-base leading-relaxed drop-shadow-lg">
{t('intro.howToPlay.description')}
</p>
<p className="text-yellow-500 text-sm drop-shadow">
{t('intro.reminder')}
</p>
</div>
</DialogHeader>
<div className="flex flex-col items-center gap-6 w-full">
<div className="flex items-center gap-2 self-start">
<LanguageSwitcher />
<span className="text-xs text-gray-400">
{t('languageSwitcher.hint')}
</span>
</div>
<Button
onClick={handleBeginSimulation}
className="bg-yellow-500 hover:bg-yellow-600 text-black font-semibold w-full py-6 text-lg"
>
{t('buttons.beginSimulation')}
</Button>
</div>
</DialogContent>
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-[120px] pointer-events-none transition-opacity duration-300",
"bg-gradient-to-t from-black from-10% via-black/90 via-50% to-transparent to-100%",
showGradient ? "opacity-95" : "opacity-0"
)}
/>
</Dialog>
);
};

Просмотреть файл

@ -1,18 +0,0 @@
export const EXPERT_AUDIO = {
january: {
briefing: "/audio/dr-chen-january.mp3",
voice: "Dr. Chen"
},
february: {
briefing: "/audio/dr-webb-february.mp3",
voice: "Dr. Webb"
},
march: {
briefing: "/audio/prof-morrison-march.mp3",
voice: "Professor Morrison"
},
april: {
briefing: "/audio/agent-torres-april.mp3",
voice: "Agent Torres"
}
};

Просмотреть файл

@ -4,27 +4,13 @@ import { ExpertMemo } from '../ExpertMemo';
import { useTranslation } from 'react-i18next';
import { ChoiceID } from './metrics';
// Define month indices as constants
export const MONTHS = {
JANUARY: 0,
MARCH: 1,
MAY: 2,
JULY: 3,
SEPTEMBER: 4,
NOVEMBER: 5,
DECEMBER: 6,
ALERT: 7,
EXPOSÉ: 8,
} as const;
// Create a custom hook to handle stages with translations
export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): GameStage[] => {
const { t } = useTranslation();
// Helper function to get translated month title
const getMonthTitle = (monthIndex: number) => {
const monthKeys = ['january', 'march', 'may', 'july', 'september', 'november', 'december', 'alert', 'exposé'];
return t(`months.${monthKeys[monthIndex]}`);
const getMonthTitle = (stage: number) => {
return t(`months.${stage}`);
};
// Helper function to get translated choice option
@ -35,8 +21,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
return [
{
id: 1,
monthIndex: MONTHS.JANUARY,
title: getMonthTitle(MONTHS.JANUARY),
monthIndex: 1, // January
title: getMonthTitle(1),
description: <ExpertMemo
from={t('stages.1.expertMemo.from')}
subject={t('stages.1.expertMemo.subject')}
@ -114,8 +100,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 2,
monthIndex: MONTHS.MARCH,
title: getMonthTitle(MONTHS.MARCH),
monthIndex: 2, // March
title: getMonthTitle(2),
description: <ExpertMemo
from={t('stages.2.expertMemo.from')}
subject={t('stages.2.expertMemo.subject')}
@ -191,8 +177,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 3,
monthIndex: MONTHS.MAY,
title: getMonthTitle(MONTHS.MAY),
monthIndex: 3, // May
title: getMonthTitle(3),
description: <ExpertMemo
from={t('stages.3.expertMemo.from')}
subject={t('stages.3.expertMemo.subject')}
@ -269,8 +255,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 4,
monthIndex: MONTHS.ALERT,
title: getMonthTitle(MONTHS.ALERT),
monthIndex: 4, // Alert
title: getMonthTitle(4),
description: <ExpertMemo
from={t('stages.4.expertMemo.from')}
subject={t('stages.4.expertMemo.subject')}
@ -348,8 +334,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 5,
monthIndex: MONTHS.JULY,
title: getMonthTitle(MONTHS.JULY),
monthIndex: 5, // July
title: getMonthTitle(5),
description: <ExpertMemo
from={t('stages.5.expertMemo.from')}
subject={t('stages.5.expertMemo.subject')}
@ -426,8 +412,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 6,
monthIndex: MONTHS.SEPTEMBER,
title: getMonthTitle(MONTHS.SEPTEMBER),
monthIndex: 6, // September
title: getMonthTitle(6),
description: <ExpertMemo
from={t('stages.6.expertMemo.from')}
subject={t('stages.6.expertMemo.subject')}
@ -504,8 +490,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 7,
monthIndex: MONTHS.NOVEMBER,
title: getMonthTitle(MONTHS.NOVEMBER),
monthIndex: 7, // November
title: getMonthTitle(7),
description: <ExpertMemo
from={t('stages.7.expertMemo.from')}
subject={t('stages.7.expertMemo.subject')}
@ -582,8 +568,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 8,
monthIndex: MONTHS.DECEMBER,
title: getMonthTitle(MONTHS.DECEMBER),
monthIndex: 8, // December
title: getMonthTitle(8),
description: <ExpertMemo
from={t('stages.8.expertMemo.from')}
subject={t('stages.8.expertMemo.subject')}
@ -660,8 +646,8 @@ export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): Game
},
{
id: 9,
monthIndex: MONTHS.EXPOSÉ,
title: getMonthTitle(MONTHS.EXPOSÉ),
monthIndex: 9, // Exposé
title: getMonthTitle(9),
description: <ExpertMemo
from={t('stages.9.expertMemo.from')}
subject={t('stages.9.expertMemo.subject')}

Просмотреть файл

@ -1,6 +1,5 @@
export * from './loadingMessages';
export * from './operationNames';
export * from './expertAudio';
export * from './gameStages';
export * from './choiceImplications';
export * from './metrics';

Просмотреть файл

@ -1,15 +1,11 @@
import { ChoiceID } from './constants/metrics';
import { ExpertAudio } from '@/utils/months';
export interface LoadingMessage {
action: string;
duration: number;
}
export interface ExpertAudio {
briefing: string;
voice: string;
}
export interface StrategyAnimation {
type: "network" | "meme" | "news" | "community" | "expert" | "research" |
"podcast" | "event" | "platform" | "freedom" | "influencer" | "silence" |

Просмотреть файл

@ -1,25 +1,52 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import { useEffect } from "react"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
useEffect(() => {
const updateCountdown = () => {
const toasts = document.querySelectorAll('[data-sonner-toast]')
toasts.forEach(toast => {
const createdAt = Number(toast.getAttribute('data-created'))
const duration = 4000 // Match the duration from toastOptions
const now = Date.now()
const remaining = Math.max(0, Math.ceil((createdAt + duration - now) / 1000))
toast.setAttribute('data-remaining', remaining.toString())
})
}
const interval = setInterval(updateCountdown, 100)
return () => clearInterval(interval)
}, [])
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
duration={4000}
position="top-right"
closeButton
richColors
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-2 group-[.toaster]:border-yellow-500/30 group-[.toaster]:shadow-[0_0_10px_rgba(234,179,8,0.1)] group-[.toaster]:shadow-lg relative",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
"group-[.toast]:bg-yellow-500/10 group-[.toast]:text-yellow-500 group-[.toast]:hover:bg-yellow-500/20",
closeButton:
"group-[.toast]:bg-yellow-500/10 group-[.toast]:text-yellow-500 group-[.toast]:hover:bg-yellow-500/20",
},
descriptionClassName: "text-sm text-yellow-500/70",
style: {
'--duration': '4000ms',
} as React.CSSProperties,
}}
{...props}
/>

Просмотреть файл

@ -1,127 +0,0 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

Просмотреть файл

@ -1,33 +0,0 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

Просмотреть файл

@ -1,3 +0,0 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };

Просмотреть файл

@ -1,191 +0,0 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

Просмотреть файл

@ -93,3 +93,37 @@
@apply bg-background text-foreground;
}
}
/* Toast Customization */
[data-sonner-toaster] [data-sonner-toast] {
position: relative;
}
[data-sonner-toaster] [data-sonner-toast]::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: rgb(234 179 8 / 0.3);
animation: toast-progress var(--duration, 4000ms) linear;
}
[data-sonner-toaster] [data-sonner-toast]::before {
content: 'Auto-dismissing in ' attr(data-remaining) 's';
position: absolute;
bottom: 4px;
right: 8px;
font-size: 0.7rem;
color: rgb(234 179 8 / 0.5);
}
@keyframes toast-progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}

Просмотреть файл

@ -10,7 +10,6 @@ import { IntroDialog } from "../components/game/IntroDialog";
import { useGameStages, OPERATION_NAMES, useLoadingMessages, generateFinalReport } from "@/components/game/constants";
import { ChoiceID, calculateMetrics } from "@/components/game/constants/metrics";
import { DossierEntry, GameStage } from "@/components/game/types";
import { useToast } from "@/components/ui/use-toast";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
@ -38,18 +37,11 @@ import { MetricsDisplay } from "@/components/game/MetricsDisplay";
import { MuteButton } from '@/components/MuteButton';
import { DevPanel } from "@/components/game/DevPanel";
import { motion } from "framer-motion";
import { MONTHS_CONFIG, getMonthConfig } from "@/utils/months";
import { toast } from "sonner";
const monthKeys = [
'january', // 0
'march', // 1
'may', // 2
'alert', // 3
'july', // 4
'september', // 5
'november', // 6
'december', // 7
'exposé' // 8
];
// Get valid month keys (skipping index 0)
const monthKeys = MONTHS_CONFIG.slice(1).map(config => config?.key).filter(Boolean) as string[];
const STAGE_CHOICES = [
['DEPLOY_BOTS', 'ESTABLISH_MEMES'], // January
@ -76,7 +68,6 @@ const Index = () => {
const [showingResult, setShowingResult] = useState(false);
const [currentResult, setCurrentResult] = useState<GameStage["choices"][0]["result"] | null>(null);
const [dossierEntries, setDossierEntries] = useState<DossierEntry[]>([]);
const { toast } = useToast();
const [showingMonthTransition, setShowingMonthTransition] = useState(false);
const [nextStage, setNextStage] = useState<number | null>(null);
const [transitionStyle, setTransitionStyle] = useState<TransitionStyle>(TransitionStyle.NUMBER_CYCLE);
@ -144,10 +135,7 @@ const Index = () => {
const handleInitialTransitionComplete = () => {
setShowingInitialTransition(false);
setGameStarted(true);
toast({
title: t('mission.welcome.title'),
description: t('mission.welcome.description'),
});
toast(t('mission.welcome.description'));
};
const handleChoice = async (choice: GameStage["choices"][0]) => {
@ -208,17 +196,7 @@ const Index = () => {
setShowingResult(true);
const newEntry: DossierEntry = {
dateKey: stages[currentStage].monthIndex === 0 ? 'months.january' :
stages[currentStage].monthIndex === 1 ? 'months.february' :
stages[currentStage].monthIndex === 2 ? 'months.march' :
stages[currentStage].monthIndex === 3 ? 'months.april' :
stages[currentStage].monthIndex === 4 ? 'months.may' :
stages[currentStage].monthIndex === 5 ? 'months.june' :
stages[currentStage].monthIndex === 6 ? 'months.july' :
stages[currentStage].monthIndex === 7 ? 'months.august' :
stages[currentStage].monthIndex === 8 ? 'months.september' :
stages[currentStage].monthIndex === 9 ? 'months.october' :
stages[currentStage].monthIndex === 10 ? 'months.november' : 'months.december',
dateKey: `months.${getMonthConfig(currentStage + 1)?.key}`,
titleKey: `stages.${currentStage + 1}.choices.${choice.id}.result.title`,
insightKeys: Array.from({ length: 4 }, (_, i) => `stages.${currentStage + 1}.choices.${choice.id}.result.insights.${i}`),
strategicNoteKey: `stages.${currentStage + 1}.choices.${choice.id}.result.nextStepHint`
@ -296,7 +274,7 @@ const Index = () => {
<div className="relative min-h-screen bg-transparent p-4 flex items-center justify-center">
<div className="max-w-4xl mx-auto w-full relative">
<MonthTransition
monthIndex={stages[0]?.monthIndex ?? 1}
stage={1}
onComplete={handleInitialTransitionComplete}
style={TransitionStyle.NUMBER_CYCLE}
/>
@ -521,7 +499,7 @@ const Index = () => {
<GameBackground shouldStartAudio={shouldStartAudio} />
<div className="relative min-h-screen bg-transparent p-4 flex items-center justify-center">
<MonthTransition
monthIndex={stages[nextStage]?.monthIndex ?? nextStage + 1}
stage={nextStage + 1}
onComplete={handleTransitionComplete}
style={transitionStyle}
/>
@ -542,7 +520,13 @@ const Index = () => {
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<MuteButton />
<span className="text-yellow-500 font-mono text-lg">{t(`months.${monthKeys[currentStageData.monthIndex]}`)}</span>
<span className="text-yellow-500 font-mono text-lg">
{(() => {
console.log('Index - currentStageData:', currentStageData);
const monthConfig = getMonthConfig(currentStage + 1);
return t(`months.${monthConfig?.key}`);
})()}
</span>
</div>
{currentStage > 0 && <DossierPanel entries={dossierEntries} choices={previousChoices} />}
</div>
@ -553,16 +537,19 @@ const Index = () => {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{currentStageData.choices.map((choice, index) => (
<ChoiceCard
key={choice.id}
choice={choice}
previousChoices={previousChoices}
onClick={() => handleStrategyClick(choice)}
disabled={showingResult || isLoading}
optionNumber={index + 1}
/>
))}
{(() => {
console.log('Index - Rendering stage:', currentStage);
return currentStageData.choices.map((choice, index) => (
<ChoiceCard
key={choice.id}
choice={choice}
previousChoices={previousChoices}
onClick={() => handleStrategyClick(choice)}
disabled={showingResult || isLoading}
optionNumber={index + 1}
/>
));
})()}
</div>
</CardContent>
<div className="mt-4 border-t border-gray-700/50">

100
src/utils/months.ts Обычный файл
Просмотреть файл

@ -0,0 +1,100 @@
export interface ExpertAudio {
briefing: string;
voice: string;
}
export interface AudioConfig {
briefing: string;
voice: string;
}
export interface MonthConfig {
key: string;
// This key can be used with your localization i18n system.
translationKey: string;
audio?: AudioConfig;
}
// Using a sparse array where index matches stage number
// Index 0 is empty, stages start at 1
export const MONTHS_CONFIG: (MonthConfig | undefined)[] = [];
// January is stage 1
MONTHS_CONFIG[1] = {
key: "january",
translationKey: "months.january",
audio: {
briefing: "january-en.mp3",
voice: "Dr. Chen"
}
};
// March is stage 2
MONTHS_CONFIG[2] = {
key: "march",
translationKey: "months.march",
audio: {
briefing: "march-en.mp3",
voice: "Professor Morrison"
}
};
// May is stage 3
MONTHS_CONFIG[3] = {
key: "may",
translationKey: "months.may",
audio: {
briefing: "may-en.mp3",
voice: "Dr. Chen"
}
};
// Alert is stage 4
MONTHS_CONFIG[4] = {
key: "alert",
translationKey: "months.alert",
audio: {
briefing: "alert-en.mp3",
voice: "System Alert"
}
};
// July is stage 5
MONTHS_CONFIG[5] = {
key: "july",
translationKey: "months.july",
audio: {
briefing: "july-en.mp3",
voice: "Dr. Webb"
}
};
// September is stage 6
MONTHS_CONFIG[6] = {
key: "september",
translationKey: "months.september"
};
// November is stage 7
MONTHS_CONFIG[7] = {
key: "november",
translationKey: "months.november"
};
// December is stage 8
MONTHS_CONFIG[8] = {
key: "december",
translationKey: "months.december"
};
// Exposé is stage 9
MONTHS_CONFIG[9] = {
key: "exposé",
translationKey: "months.exposé"
};
// Utility function to get month config - now much simpler!
export function getMonthConfig(stage: string | number): MonthConfig | undefined {
const stageNum = typeof stage === 'string' ? parseInt(stage) : stage;
return MONTHS_CONFIG[stageNum];
}

Просмотреть файл

@ -1,7 +1,17 @@
export const VERSION = {
current: '0.3.0',
current: '0.4.1',
releaseDate: '2024-12-16',
changelog: {
'0.4.1': [
'Month index fixes and consolidation with stage index',
'Enhanced metrics and KPI visualization',
'Improved dossier panel',
],
'0.4.0': [
'Major layout changes and improvements',
'Improved mission briefing UI',
'Added dev panel for testing',
],
'0.3.0': [
'Added KPIs',
'Revamped strength and weakness mechanics',