desenvolvimento pre-lancamento
Commit inicial - add do repo privado para o repo NT style: changes header's logo and colors style: changes home page first session layout feat: creates about us home page section chore: creates home page section for whom chore: creates student materails home page section chore: creates teachers materials home page section chore: creates teacher materials home page section style: changes primary color style: changes color at activities page style: changes about page color style: changes name to Decoda fix: changes route to about page at footer fix: changes background color style: changes game page header colors style: changes footer colors chore: adds home page sections title style: changes main font family to Lato style: adds title font fix: changes sizes to be more responsive for mobile ajuste no build vercel atualiza regras envio homol Adiciona instrucoes de uso add JupyterLite fix solucao turtle Add Mole Mash e Modal de Falhas Add Progress Bar na pagina de Atividades fix game name chore: atualiza lockfile removendo vercel analytics inclusão de efeito ao mudar de fase add mecanismo de solução de fases em debug vite config test add BaseGame e refator do MoleMash refatoração turtle refatoração automato refatoração automato add tag bug 1 e 2 automato mostrar apenas games em homologação na pagina de atividades aumentar timeout das fases finais do Turtle fix bug scroll add video refactor semaforo arrumar ordem das cores add build docs update vercel update vercel update vercel update vercel update vercel add vercel jupyter add vercel jupyter fix deploy Vercel fix deploy Vercel fix deploy Vercel add cripto add cripto refatoração fix tour Mole Mash . remover arquivos de controle chore: adds development tag for activity card remover arquivos de status indevidamente versionados atualizar cores nas atividades add Quebra Cabeças add Quebra Cabeças add iniciativas add Iniciativas alteração de fotos pesadas fix menu mobile fix menu mobile fix menu mobile add Aspirador update icons update identidade visual documentação update jupyter add kernel python local add kernel python local add kernel python local feat: add health check feat: add primeiros passos add letramento mover letramento de lugar update path games update path games fix: ajuste clique rapido no botão executar remover dead code fix: refactor: extract shared utilities for storage, phase unlock and mobile detection stabilize context references and fix stale closure extrair GameProgressContext do GameStateContext (SRP) refactor(game): extrair usePhaser e useGameModals de GameBase + corrigir bugs descobertos refactor(game): remove todos os aliases PT/EN duplicados Remover aliases PT/EN da camada de modais refactor + tests security: add CodeSanitizer and integrate into GameInterpreter - CodeSanitizer.js: 4 built-in rules (max_length, infinite_while, infinite_for, excessive_nesting) with pluggable extra rules - GameInterpreter.executeCode: calls sanitizeCode() before js-interpreter, differentiates CodeSanitizationError (warn) from other errors (error) - 21 unit tests for CodeSanitizer (100% coverage) - 4 integration tests in GameInterpreter for sanitization paths add CodeSanitizer fix: fase 10 aspirador fix: bug semaforo teste feat: add version Ajusta a landing page para ficar mais próxima ao protótipo ajusta raio da borda do botão de Acesse nosso Laboratório pequenos ajustes de layout na página de iniciativas atualiza tabela de jogos educativos com os jogos disponíveis atualmente ajustados pequenos detalhes e informações do jogos na seção de guias pedagógicos troca nome playground para laboratório e adiciona imagens do lab adiciona documentação de conceitos básicos de programação ajustado pequenos erros de digitação adiciona tooltip com conceitos escondidos em hover na tag +N de conceitos update docs dev desativar tour setup matriz MoleMash setup matriz MoleMash fix: link update version update docs update docs mudou o layout de quem somos mudei as imgs dos icons e baixei o botao centraliza titulo com imagem e ajusta sessão com gradiente vermelho-rosa adiciona responsividade para a pagina quem somos ajusta botão de conheça nossa história ajustes ajustes na home + add. teclado update version security security feat: add tapume para telas pequenas v1.1.0 feat: decoda offline feat: doc offline offline fix: ajustes para release fix: navbar; config ordenação; versão fix: rotas docs e jupyter para pwa delete private files Co-authored-by: Indra Araujo <indra.araujo.santos@gmail.com> Co-authored-by: solange dos santos <sollangelive71@gmail.com>
This commit is contained in:
58
app/src/components/DesktopOnlyTapume.jsx
Normal file
58
app/src/components/DesktopOnlyTapume.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MonitorOff, Construction, House } from "lucide-react";
|
||||
|
||||
export default function DesktopOnlyTapume({
|
||||
title = "Área disponível apenas no desktop",
|
||||
message = "Esta experiência foi preparada para telas maiores. Acesse em um computador para aproveitar todos os recursos.",
|
||||
homePath = "/",
|
||||
homeLabel = "Voltar para Home",
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<section className="relative flex-1 min-h-0 overflow-hidden primary-gradient">
|
||||
<div className="absolute inset-0 opacity-20 bg-[radial-gradient(circle_at_20%_20%,#ffffff_0%,transparent_45%),radial-gradient(circle_at_80%_80%,#d5df50_0%,transparent_38%)]" />
|
||||
|
||||
<div className="relative h-full w-full flex items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-xl rounded-3xl border border-white/25 bg-white/12 backdrop-blur-md shadow-2xl p-8 text-white">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-white/20 border border-white/35">
|
||||
<Construction className="w-6 h-6" />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-white/15 border border-white/30 px-3 py-1 text-sm font-semibold">
|
||||
<MonitorOff className="w-4 h-4" />
|
||||
Uso em desktop
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-title text-3xl leading-tight mb-3">{title}</h1>
|
||||
<p className="font-sans text-base md:text-lg text-white/95 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 inline-flex items-center rounded-full bg-black/20 border border-white/25 px-4 py-2 text-sm font-medium">
|
||||
Dica: em telas maiores, esta área libera editor e interações completas.
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(homePath)}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[var(--action-green)] text-black px-5 py-3 font-semibold shadow-lg transition-transform duration-200 hover:scale-[1.02]"
|
||||
>
|
||||
<House className="w-4 h-4" />
|
||||
{homeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
DesktopOnlyTapume.propTypes = {
|
||||
title: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
homePath: PropTypes.string,
|
||||
homeLabel: PropTypes.string,
|
||||
};
|
||||
240
app/src/components/Navbar.jsx
Normal file
240
app/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @fileoverview React component for Navbar.jsx
|
||||
*
|
||||
* @module components.Navbar
|
||||
*/
|
||||
|
||||
import { isSession, Link } from "react-router-dom";
|
||||
import { Menu, X, ChevronDown } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import logo from "../assets/logo_decoda.svg";
|
||||
|
||||
const Navbar = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [openDropdown, setOpenDropdown] = useState(null);
|
||||
const [closeTimeout, setCloseTimeout] = useState(null);
|
||||
const isOffline = import.meta.env.VITE_IS_OFFLINE === "true";
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll(); // Check initial state
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = (dropdown) => {
|
||||
if (closeTimeout) {
|
||||
clearTimeout(closeTimeout);
|
||||
setCloseTimeout(null);
|
||||
}
|
||||
setOpenDropdown(dropdown);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const timeout = setTimeout(() => {
|
||||
setOpenDropdown(null);
|
||||
}, 150);
|
||||
setCloseTimeout(timeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 border-b border-white/20 transition-all duration-300 bg-white`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-1 lg:px-12 lg:py-12">
|
||||
{/* Logo - Esquerda */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="header-logo group cursor-pointer flex items-center space-x-2 hover:bg-white/10 px-3 py-2 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Decoda Logo"
|
||||
className="h-[83px] transition-transform duration-200 group-hover:scale-110"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Espaço Central vazio para empurrar o menu para a direita */}
|
||||
<div className="flex-1 flex justify-center"></div>
|
||||
|
||||
{/* Menu Desktop - Direita */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="hidden md:flex items-center gap-6 lg:gap-8">
|
||||
{/* Links diretos - Primeiros Passos */}
|
||||
<Link
|
||||
to="/primeiros-passos"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Primeiros Passos
|
||||
</Link>
|
||||
{/* Links diretos - Atividades */}
|
||||
<Link
|
||||
to="/atividades"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Programação
|
||||
</Link>
|
||||
|
||||
{/* Dropdown - Laboratórios */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => handleMouseEnter("laboratorios")}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button className="flex items-center gap-1 text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4">
|
||||
Laboratórios
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
{openDropdown === "laboratorios" && (
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||
<Link
|
||||
to="/playground"
|
||||
className="block px-4 py-2 text-black hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Laboratório de Blocos
|
||||
</Link>
|
||||
{(isOffline === false) && (
|
||||
<Link
|
||||
to="/laboratorio-python/"
|
||||
className="block px-4 py-2 text-black hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Laboratório Python
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links diretos - Iniciativas */}
|
||||
<Link
|
||||
to="/iniciativas"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Iniciativas
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/sobre"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Quem somos
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/faq"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Perguntas frequentes
|
||||
</Link>
|
||||
|
||||
{(isOffline === false) && (
|
||||
<Link
|
||||
to="/educadores"
|
||||
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
|
||||
>
|
||||
Para educadores
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão Mobile Menu */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<X className="size-10 text-black" />
|
||||
) : (
|
||||
<Menu className="size-10 text-black" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Mobile */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/20 bg-white backdrop-blur-sm">
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
{/* Links diretos Mobile */}
|
||||
<Link
|
||||
to="/atividades"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Atividades
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/primeiros-passos"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Primeiros Passos
|
||||
</Link>
|
||||
|
||||
{/* Laboratórios no mobile */}
|
||||
<div className="space-y-1">
|
||||
<div className="px-4 py-2 text-black font-medium">
|
||||
Laboratórios
|
||||
</div>
|
||||
<Link
|
||||
to="/playground"
|
||||
className="block pl-8 pr-4 py-2 text-black hover:bg-black/10 rounded-lg transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Laboratório de Blocos
|
||||
</Link>
|
||||
<Link
|
||||
to="/laboratorio-python/"
|
||||
className="block pl-8 pr-4 py-2 text-black hover:bg-black/10 rounded-lg transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Laboratório Python
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/iniciativas"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Iniciativas
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/sobre"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Quem somos
|
||||
</Link>
|
||||
<Link
|
||||
to="/faq"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Perguntas frequentes
|
||||
</Link>
|
||||
<Link
|
||||
to="/educadores"
|
||||
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Para educadores
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
22
app/src/components/ScrollToTop.jsx
Normal file
22
app/src/components/ScrollToTop.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Component to scroll to top on route change
|
||||
*
|
||||
* @module components.ScrollToTop
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Component that scrolls window to top whenever the route changes.
|
||||
* Must be placed inside Router.
|
||||
*/
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
99
app/src/components/game/ConfettiOverlay.jsx
Normal file
99
app/src/components/game/ConfettiOverlay.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @fileoverview React component for ConfettiOverlay.jsx
|
||||
*
|
||||
* @module components.game.ConfettiOverlay
|
||||
*/
|
||||
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { confetti } from "@tsparticles/confetti";
|
||||
|
||||
const ConfettiOverlay = ({ isActive, onComplete, // english alias
|
||||
active, onFinish }) => {
|
||||
const isActiveEffective = isActive ?? active;
|
||||
const onCompleteEffective = onComplete ?? onFinish;
|
||||
const canvasRef = useRef(null);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActiveEffective && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
const randomInRange = (min, max) => {
|
||||
return Math.random() * (max - min) + min;
|
||||
};
|
||||
|
||||
const triggerConfettiBlast = async () => {
|
||||
const defaultOptions = {
|
||||
angle: randomInRange(55, 125),
|
||||
spread: randomInRange(50, 70),
|
||||
particleCount: randomInRange(50, 100),
|
||||
origin: { y: 0.6 },
|
||||
canvas: canvas,
|
||||
};
|
||||
|
||||
await confetti(defaultOptions);
|
||||
};
|
||||
|
||||
triggerConfettiBlast();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (onCompleteEffective) {
|
||||
onCompleteEffective();
|
||||
}
|
||||
}, 500);
|
||||
} else if (!isActive && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActiveEffective, onCompleteEffective]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCanvasSize = () => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.width = window.innerWidth;
|
||||
canvasRef.current.height = window.innerHeight;
|
||||
}
|
||||
};
|
||||
|
||||
updateCanvasSize();
|
||||
window.addEventListener("resize", updateCanvasSize);
|
||||
return () => window.removeEventListener("resize", updateCanvasSize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 9999,
|
||||
opacity: isActiveEffective ? 1 : 0,
|
||||
transition: "opacity 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ConfettiOverlay.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
active: PropTypes.bool,
|
||||
onComplete: PropTypes.func,
|
||||
onFinish: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ConfettiOverlay;
|
||||
72
app/src/components/game/ConfirmacaoModal.jsx
Normal file
72
app/src/components/game/ConfirmacaoModal.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @fileoverview React component for ConfirmacaoModal.jsx
|
||||
*
|
||||
* @module components.game.ConfirmacaoModal
|
||||
*/
|
||||
|
||||
// components/game/ConfirmacaoModal.jsx
|
||||
import { X } from "lucide-react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function ConfirmacaoModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl max-w-md w-full p-6 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800">{title}</h3>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mensagem */}
|
||||
<p className="text-gray-700 mb-6">{message}</p>
|
||||
|
||||
{/* Ações */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
onClick={() => {
|
||||
if (onConfirm) onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConfirmacaoModal.propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
onConfirm: PropTypes.func,
|
||||
title: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
};
|
||||
100
app/src/components/game/FalhaModal.jsx
Normal file
100
app/src/components/game/FalhaModal.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @fileoverview React component for FalhaModal.jsx
|
||||
*
|
||||
* @module components.game.FalhaModal
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import { ModalBase } from "./modal/ModalBase";
|
||||
import { ModalHeader } from "./modal/ModalHeader";
|
||||
import { CodeArea } from "./modal/CodeArea";
|
||||
import { FeedbackBox } from "./modal/FeedbackBox";
|
||||
|
||||
const FalhaModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onRetry,
|
||||
customMessage,
|
||||
currentPhase,
|
||||
generatedCode,
|
||||
}) => {
|
||||
const handleRetry = () => {
|
||||
onClose();
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
}
|
||||
};
|
||||
|
||||
const mensagemExibida =
|
||||
customMessage ??
|
||||
"Ops! Parece que algo não funcionou como esperado. Tente novamente!";
|
||||
|
||||
return (
|
||||
<ModalBase isOpen={isOpen} onClose={onClose}>
|
||||
<ModalHeader
|
||||
title="Quase lá! Tente novamente"
|
||||
subTitle={`Fase ${currentPhase}`}
|
||||
variant="failure"
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
||||
<p className="text-red-800 font-medium">{mensagemExibida}</p>
|
||||
</div>
|
||||
|
||||
<CodeArea
|
||||
title="Seu Código Atual"
|
||||
code={generatedCode}
|
||||
variant="failure"
|
||||
/>
|
||||
|
||||
<FeedbackBox title="Dicas para o Desafio" variant="failure">
|
||||
<ul className="text-amber-800 text-sm space-y-1 ml-4 list-disc">
|
||||
<li>Revise o enunciado.</li>
|
||||
<li>Verifique se os blocos estão corretamente conectados.</li>
|
||||
<li>
|
||||
Certifique-se de que a lógica atende a todos os requisitos da
|
||||
fase.
|
||||
</li>
|
||||
</ul>
|
||||
</FeedbackBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Rodapé (Botões de Ação) */}
|
||||
<div className="p-6 border-t border-gray-200 flex justify-between items-center bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-full font-medium transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Tentar Novamente</span>
|
||||
</button>
|
||||
</div>
|
||||
</ModalBase>
|
||||
);
|
||||
};
|
||||
|
||||
FalhaModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onRetry: PropTypes.func,
|
||||
customMessage: PropTypes.string,
|
||||
currentPhase: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
generatedCode: PropTypes.string,
|
||||
};
|
||||
|
||||
export default FalhaModal;
|
||||
138
app/src/components/game/GameArea.jsx
Normal file
138
app/src/components/game/GameArea.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview React component for GameArea.jsx
|
||||
*
|
||||
* @module components.game.GameArea
|
||||
*/
|
||||
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useGameState, GAME_STATES } from "../../contexts/GameStateContext";
|
||||
import { gameEventBus } from "../../utils/gameEvents";
|
||||
import ConfettiOverlay from "./ConfettiOverlay";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function GameArea({
|
||||
children,
|
||||
blocksRemaining = null,
|
||||
phaseId = null,
|
||||
remainingBlocksLabel = null,
|
||||
}) {
|
||||
const {
|
||||
executionState,
|
||||
generatedCode,
|
||||
finalizeWithSuccess,
|
||||
finalizeWithFailure,
|
||||
setFailureMessage,
|
||||
} = useGameState();
|
||||
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const previousPhaseId = useRef(phaseId);
|
||||
const effectivePhaseId = phaseId;
|
||||
const remainingBlocks = blocksRemaining;
|
||||
|
||||
useEffect(() => {
|
||||
if (effectivePhaseId !== null && effectivePhaseId !== previousPhaseId.current) {
|
||||
setIsTransitioning(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
previousPhaseId.current = effectivePhaseId;
|
||||
}, 600);
|
||||
|
||||
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [effectivePhaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGameSuccess = () => {
|
||||
finalizeWithSuccess();
|
||||
setShowConfetti(true);
|
||||
};
|
||||
|
||||
const handleGameFailure = (e) => {
|
||||
const reason = e.detail?.reason;
|
||||
if (reason) setFailureMessage(reason);
|
||||
finalizeWithFailure();
|
||||
};
|
||||
|
||||
gameEventBus.addEventListener("gameSuccess", handleGameSuccess);
|
||||
gameEventBus.addEventListener("gameFailure", handleGameFailure);
|
||||
|
||||
return () => {
|
||||
gameEventBus.removeEventListener("gameSuccess", handleGameSuccess);
|
||||
gameEventBus.removeEventListener("gameFailure", handleGameFailure);
|
||||
};
|
||||
}, [finalizeWithSuccess, finalizeWithFailure]);
|
||||
|
||||
useEffect(() => {
|
||||
switch (executionState) {
|
||||
case GAME_STATES.EXECUTANDO:
|
||||
if (generatedCode) {
|
||||
const codigo =
|
||||
typeof generatedCode === "string"
|
||||
? generatedCode
|
||||
: generatedCode.codigo;
|
||||
const ws =
|
||||
typeof generatedCode === "object" ? generatedCode.workspace : null;
|
||||
|
||||
gameEventBus.executeCode(codigo, ws);
|
||||
}
|
||||
break;
|
||||
case GAME_STATES.PARADO:
|
||||
gameEventBus.resetGame();
|
||||
setShowConfetti(false);
|
||||
break;
|
||||
}
|
||||
}, [executionState, generatedCode]);
|
||||
|
||||
const remainingLabel = remainingBlocksLabel ?? "Blocos restantes";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full overflow-hidden relative flex items-center justify-center game-area-container bg-gray-900"
|
||||
id="visualization"
|
||||
>
|
||||
{/* Confetti de sucesso de uma fase */}
|
||||
<ConfettiOverlay isActive={showConfetti} />
|
||||
|
||||
{/* Overlay de transição */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-gray-900 transition-opacity duration-300 ease-in-out z-10 ${
|
||||
isTransitioning ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
style={{ borderRadius: "5px" }}
|
||||
>
|
||||
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
{/* Indicador de blocos restantes */}
|
||||
{remainingBlocks !== null && !isNaN(remainingBlocks) && (
|
||||
<div id="capacityBubble">
|
||||
<div id="capacity" style={{ display: "block" }}>
|
||||
{remainingLabel}: {" "}
|
||||
<span className="capacityNumber">{remainingBlocks}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full phaser-container"
|
||||
style={{
|
||||
backgroundColor: "#242527",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameArea.propTypes = {
|
||||
children: PropTypes.node,
|
||||
blocksRemaining: PropTypes.number,
|
||||
phaseId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
remainingBlocksLabel: PropTypes.string,
|
||||
};
|
||||
213
app/src/components/game/GameBase.jsx
Normal file
213
app/src/components/game/GameBase.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @fileoverview React component for GameBase.jsx
|
||||
*
|
||||
* @module components.game.GameBase
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Panel, PanelGroup } from "react-resizable-panels";
|
||||
import GameNavBar from "./GameNavBar";
|
||||
import GameFaseInfo from "./GameFaseInfo";
|
||||
import GameArea from "./GameArea";
|
||||
import GameFooter from "./GameFooter";
|
||||
import SeletorDeFases from "./SeletorDeFases";
|
||||
import SucessoModal from "./SucessoModal";
|
||||
import FalhaModal from "./FalhaModal";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||
import { useGameState } from "../../contexts/GameStateContext";
|
||||
import { EditorProvider } from "../../contexts/EditorContext";
|
||||
import { usePhaser } from "../../hooks/usePhaser";
|
||||
import { useGameModals } from "../../hooks/useGameModals";
|
||||
|
||||
function GameBaseContent({
|
||||
gameFactory,
|
||||
gameConfig,
|
||||
children,
|
||||
onHelpClick,
|
||||
customFailureHandler,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
currentPhase,
|
||||
setCurrentPhase,
|
||||
resetProgress,
|
||||
executionState,
|
||||
generatedCode,
|
||||
failureMessage,
|
||||
restart,
|
||||
setOnWorkspaceChange,
|
||||
} = useGameState();
|
||||
|
||||
const phaseConfig = gameConfig.fases[currentPhase - 1];
|
||||
const usaModalFalha = !!customFailureHandler;
|
||||
|
||||
const { gameContainerRef } = usePhaser({
|
||||
gameFactory,
|
||||
phaseConfig,
|
||||
currentPhase,
|
||||
customFailureHandler,
|
||||
gameConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
modalFasesAberto,
|
||||
setModalFasesAberto,
|
||||
modalSucessoAberto,
|
||||
modalFalhaAberto,
|
||||
blocksRemainingCount,
|
||||
handleProximaFase,
|
||||
handleFecharModalSucesso,
|
||||
handleFecharModalFalha,
|
||||
handleTentarNovamente,
|
||||
} = useGameModals({
|
||||
executionState,
|
||||
currentPhase,
|
||||
phaseConfig,
|
||||
gameConfig,
|
||||
setCurrentPhase,
|
||||
restart,
|
||||
setOnWorkspaceChange,
|
||||
usaModalFalha,
|
||||
});
|
||||
|
||||
const handleResetProgresso = () => {
|
||||
resetProgress();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("resetBlocklyWorkspace", {
|
||||
detail: { gameId: gameConfig.gameId },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const codeToShow = React.useMemo(() => {
|
||||
if (!generatedCode) return "Nenhum código gerado";
|
||||
|
||||
let codigo = "";
|
||||
|
||||
if (typeof generatedCode === "string") {
|
||||
codigo = generatedCode;
|
||||
} else if (typeof generatedCode === "object" && generatedCode.codigo) {
|
||||
codigo = generatedCode.codigo;
|
||||
} else {
|
||||
return "Código não disponível";
|
||||
}
|
||||
|
||||
const codigoLimpo = codigo
|
||||
.split("\n")
|
||||
.filter((linha) => !linha.trim().startsWith("highlightBlock("))
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
return codigoLimpo || codigo;
|
||||
}, [generatedCode]);
|
||||
|
||||
return (
|
||||
<div className="game-base-page flex flex-col h-screen w-screen">
|
||||
<GameNavBar
|
||||
title={`${gameConfig.gameName}`}
|
||||
type={`${gameConfig.type}`}
|
||||
thumbnail={gameConfig.thumbnail}
|
||||
icon={gameConfig.icon}
|
||||
/>
|
||||
<GameFaseInfo phaseData={phaseConfig} phaseNumber={currentPhase} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<PanelGroup
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Panel defaultSize={isMobile ? 48 : 48} minSize={isMobile ? 10 : 10}>
|
||||
<EditorProvider gameConfig={gameConfig} currentPhase={currentPhase}>
|
||||
{children}
|
||||
</EditorProvider>
|
||||
</Panel>
|
||||
<ResizeHandle direction={isMobile ? "vertical" : "horizontal"} />
|
||||
<Panel defaultSize={isMobile ? 49 : 49} minSize={isMobile ? 10 : 10}>
|
||||
<GameArea blocksRemaining={blocksRemainingCount} phaseId={currentPhase}>
|
||||
<div ref={gameContainerRef} className="w-full h-full" />
|
||||
</GameArea>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
<GameFooter
|
||||
gameConfig={gameConfig}
|
||||
currentPhase={currentPhase}
|
||||
onOpenPhaseSelector={() => setModalFasesAberto(true)}
|
||||
onHelpClick={onHelpClick}
|
||||
/>
|
||||
|
||||
<SeletorDeFases
|
||||
isVisible={modalFasesAberto}
|
||||
onClose={() => setModalFasesAberto(false)}
|
||||
currentPhase={currentPhase}
|
||||
gameConfig={gameConfig}
|
||||
onChangePhase={(fase) => {
|
||||
setCurrentPhase(fase);
|
||||
setModalFasesAberto(false);
|
||||
}}
|
||||
onResetProgress={handleResetProgresso}
|
||||
/>
|
||||
|
||||
<SucessoModal
|
||||
isOpen={modalSucessoAberto}
|
||||
onClose={handleFecharModalSucesso}
|
||||
onNextPhase={handleProximaFase}
|
||||
generatedCode={codeToShow}
|
||||
currentPhase={currentPhase}
|
||||
totalPhases={gameConfig.fases.length}
|
||||
canGoNext={currentPhase < gameConfig.fases.length}
|
||||
/>
|
||||
|
||||
<FalhaModal
|
||||
isOpen={modalFalhaAberto}
|
||||
onClose={handleFecharModalFalha}
|
||||
onRetry={handleTentarNovamente}
|
||||
customMessage={failureMessage}
|
||||
currentPhase={currentPhase}
|
||||
generatedCode={codeToShow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameBase({
|
||||
gameFactory,
|
||||
gameConfig,
|
||||
children,
|
||||
onHelpClick,
|
||||
customFailureHandler,
|
||||
}) {
|
||||
return (
|
||||
<GameBaseContent
|
||||
gameFactory={gameFactory}
|
||||
gameConfig={gameConfig}
|
||||
onHelpClick={onHelpClick}
|
||||
customFailureHandler={customFailureHandler}
|
||||
>
|
||||
{children}
|
||||
</GameBaseContent>
|
||||
);
|
||||
}
|
||||
|
||||
GameBase.propTypes = {
|
||||
gameFactory: PropTypes.func.isRequired,
|
||||
gameConfig: PropTypes.shape({
|
||||
gameId: PropTypes.string.isRequired,
|
||||
gameName: PropTypes.string.isRequired,
|
||||
fases: PropTypes.array.isRequired,
|
||||
type: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
}).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onHelpClick: PropTypes.func,
|
||||
customFailureHandler: PropTypes.func,
|
||||
helpHandler: PropTypes.func,
|
||||
failureHandler: PropTypes.func,
|
||||
onHelp: PropTypes.func,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
147
app/src/components/game/GameEditor.jsx
Normal file
147
app/src/components/game/GameEditor.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview React component for GameEditor.jsx
|
||||
*
|
||||
* @module components.game.GameEditor
|
||||
*/
|
||||
|
||||
import { Play, Loader, RotateCcw, CircleAlert } from "lucide-react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useGameState, GAME_STATES } from "../../contexts/GameStateContext";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
export default function GameEditor(props) {
|
||||
const { children } = props;
|
||||
|
||||
const runText = props.runText ?? props.textoExecutar ?? props.runLabel ?? "Executar";
|
||||
const restartText = props.restartText ?? props.textoReiniciar ?? props.restartLabel ?? "Reiniciar";
|
||||
|
||||
// useRef é síncrono: leitura/escrita imediata, sem batch do React
|
||||
const isProcessingRef = useRef(false);
|
||||
const processingTimeoutRef = useRef(null);
|
||||
|
||||
const {
|
||||
executionState,
|
||||
execute,
|
||||
restart,
|
||||
stop,
|
||||
currentBlockCount,
|
||||
editorType,
|
||||
} = useGameState();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (processingTimeoutRef.current) {
|
||||
clearTimeout(processingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isExecuting = executionState === GAME_STATES.EXECUTANDO;
|
||||
const needsRestart =
|
||||
executionState === GAME_STATES.SUCESSO ||
|
||||
executionState === GAME_STATES.FALHA;
|
||||
const noBlocks = currentBlockCount === 0;
|
||||
|
||||
const handleClick = () => {
|
||||
// Leitura síncrona — funciona mesmo com cliques ultra-rápidos
|
||||
if (isProcessingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (noBlocks && !isExecuting && !needsRestart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escrita síncrona — bloqueia imediatamente
|
||||
isProcessingRef.current = true;
|
||||
|
||||
if (processingTimeoutRef.current) {
|
||||
clearTimeout(processingTimeoutRef.current);
|
||||
}
|
||||
processingTimeoutRef.current = setTimeout(() => {
|
||||
isProcessingRef.current = false;
|
||||
}, 200);
|
||||
|
||||
if (isExecuting) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsRestart) {
|
||||
restart();
|
||||
} else {
|
||||
execute();
|
||||
}
|
||||
};
|
||||
|
||||
const setStyle = () => {
|
||||
const style =
|
||||
"game-controls-custom flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md";
|
||||
|
||||
if (noBlocks) {
|
||||
return `${style} bg-gray-300 text-black cursor-not-allowed`;
|
||||
}
|
||||
|
||||
if (isExecuting) {
|
||||
return `${style} bg-[var(--action-green)] hover:bg-[rgb(235_119_63/1)]`;
|
||||
}
|
||||
|
||||
if (needsRestart) {
|
||||
return `${style} bg-[rgb(254_0_2/1)] hover:bg-[rgb(230_0_0/1)] text-white`;
|
||||
}
|
||||
|
||||
return `${style} bg-green-100 hover:bg-green-200 text-black`;
|
||||
};
|
||||
|
||||
const getEmptyStateText = () => {
|
||||
return editorType === "code"
|
||||
? "Adicione código para execute"
|
||||
: "Adicione blocos para execute";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
<div className="game-editor-controls py-1 flex flex-col items-center space-y-2">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={setStyle()}
|
||||
disabled={noBlocks && !isExecuting && !needsRestart}
|
||||
data-tour={needsRestart ? "reset-button" : "run-button"}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
<span>Executando, clique para interromper...</span>
|
||||
</>
|
||||
) : needsRestart ? (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>{restartText}</span>
|
||||
</>
|
||||
) : noBlocks ? (
|
||||
<>
|
||||
<CircleAlert className="w-4 h-4 opacity-50" />
|
||||
<span>{getEmptyStateText()}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
<span>{runText}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameEditor.propTypes = {
|
||||
runText: PropTypes.string,
|
||||
runLabel: PropTypes.string,
|
||||
textoExecutar: PropTypes.string,
|
||||
restartText: PropTypes.string,
|
||||
restartLabel: PropTypes.string,
|
||||
textoReiniciar: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
85
app/src/components/game/GameFaseInfo.jsx
Normal file
85
app/src/components/game/GameFaseInfo.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
function obterDificuldade(dadosFase) {
|
||||
// Usa a dificuldade da fase, se existir, senão calcula pelo número
|
||||
if (dadosFase?.dificuldade) {
|
||||
switch (dadosFase.dificuldade) {
|
||||
case "Fácil":
|
||||
return { nivel: "Fácil", cor: "bg-green-500", emoji: "😊" };
|
||||
case "Médio":
|
||||
return { nivel: "Médio", cor: "bg-yellow-500", emoji: "🤔" };
|
||||
case "Difícil":
|
||||
return { nivel: "Difícil", cor: "bg-orange-500", emoji: "😤" };
|
||||
case "Extremo":
|
||||
return { nivel: "Extremo", cor: "bg-red-500", emoji: "🔥" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// English alias for migration
|
||||
const getDifficulty = (phaseData) => obterDificuldade(phaseData);
|
||||
|
||||
function GameFaseInfo({ phaseData = {}, phaseNumber }) {
|
||||
const dificuldade = getDifficulty(phaseData);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="game-fase-info p-1 lg:px-3 lg:py-2">
|
||||
{phaseData && phaseData.nome ? (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Número da fase */}
|
||||
<div className="flex-shrink-0 w-8 h-8 lg:w-12 lg:h-12 bg-green-100 rounded-full flex items-center justify-center text-black text-sm lg:text-lg font-bold shadow-lg">
|
||||
{phaseNumber}
|
||||
</div>
|
||||
{/* Título/Subtítulo */}
|
||||
<div className="flex-1 min-w-0 px-3 lg:px-5">
|
||||
<h3 className="text-base lg:text-2xl font-semibold text-gray-800 truncate">
|
||||
{phaseData.nome}
|
||||
</h3>
|
||||
{phaseData.descricao && (
|
||||
<p className="text-base lg:text-lg text-gray-600 leading-tight whitespace-pre-wrap mt-1 pr-12 lg:pr-0">
|
||||
{phaseData.descricao}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Dificuldade */}
|
||||
<div className="flex-shrink-0">
|
||||
{dificuldade && (
|
||||
<div
|
||||
className={`flex items-center space-x-1 text-xs lg:text-sm text-white px-2 py-1 lg:px-3 lg:py-1.5 rounded-full font-medium ${dificuldade.cor}`}
|
||||
>
|
||||
<span className="lg:text-base">{dificuldade.emoji}</span>
|
||||
<span>{dificuldade.nivel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-green-100 rounded-full flex items-center justify-center text-black text-sm lg:text-lg font-title font-bold">
|
||||
?
|
||||
</div>
|
||||
<div className="flex-1 px-3 lg:px-5">
|
||||
<p className="text-xs lg:text-sm">
|
||||
Selecione uma fase para começar
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameFaseInfo;
|
||||
|
||||
export { getDifficulty };
|
||||
|
||||
GameFaseInfo.propTypes = {
|
||||
};
|
||||
73
app/src/components/game/GameFooter.jsx
Normal file
73
app/src/components/game/GameFooter.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview React component for GameFooter.jsx
|
||||
*
|
||||
* @module components.game.GameFooter
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function GameFooter({
|
||||
gameConfig,
|
||||
currentPhase,
|
||||
onHelpClick,
|
||||
onOpenPhaseSelector,
|
||||
}) {
|
||||
const totalPhases = gameConfig.fases.length;
|
||||
const displayPhase = currentPhase ?? currentPhase;
|
||||
|
||||
const ajuda = () => {
|
||||
if (onHelpClick) {
|
||||
onHelpClick();
|
||||
return;
|
||||
}
|
||||
alert("Recurso de ajuda será implementado em breve!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{/* Lado esquerdo - Botão de Ajuda (desativado temporariamente, tour será reimplementado) */}
|
||||
<div className="flex items-center invisible">
|
||||
<button
|
||||
onClick={ajuda}
|
||||
data-tour="help-button"
|
||||
title="Ajuda"
|
||||
className="bg-green-100 text-black font-medium py-2 px-6 lg:py-3 lg:px-9 text-sm lg:text-base rounded-full transition-all duration-200 hover:bg-green-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:ring-opacity-50"
|
||||
>
|
||||
Ajuda
|
||||
</button>
|
||||
</div>
|
||||
{/* Centro - Indicador de Fase Atual/Total */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="phase-indicator">
|
||||
<span className="text-white font-medium text-sm lg:text-xl">
|
||||
<span className="phase-current">{displayPhase}</span>
|
||||
<span className="phase-separator">/</span>
|
||||
<span className="phase-total">{totalPhases}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Lado direito - Botão do Seletor de Fases */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={onOpenPhaseSelector}
|
||||
data-tour="phase-selector"
|
||||
title="Selecionar Fase"
|
||||
className="bg-green-100 text-black font-medium py-2 px-6 lg:py-3 lg:px-9 lg:text-lg rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-300 focus:ring-opacity-50 hover:bg-green-200 hover:scale-105 hover:shadow-lg"
|
||||
>
|
||||
Fases
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameFooter.propTypes = {
|
||||
gameConfig: PropTypes.object.isRequired,
|
||||
currentPhase: PropTypes.number,
|
||||
onHelpClick: PropTypes.func,
|
||||
onOpenPhaseSelector: PropTypes.func,
|
||||
};
|
||||
152
app/src/components/game/GameNavBar.jsx
Normal file
152
app/src/components/game/GameNavBar.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview React component for GameNavBar.jsx
|
||||
*
|
||||
* @module components.game.GameNavBar
|
||||
*/
|
||||
|
||||
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import logo from "../../assets/logo_decoda.svg";
|
||||
import { Code, Puzzle } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function GameNavBar({ title, label, type = "blocks", thumbnail, icon }) {
|
||||
const titleToShow = title ?? label;
|
||||
const navigate = useNavigate();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const renderIcon = (size = "w-6 h-6 lg:w-8 lg:h-8") => {
|
||||
if (type === "code") {
|
||||
return <Code className={`text-white ${size}`} />;
|
||||
} else {
|
||||
return <Puzzle className={`text-white ${size}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Navbar normal - oculto em telas pequenas */}
|
||||
<nav className="bg-white sticky top-0 z-50 border-b border-white/20 shadow-lg hidden sm:block">
|
||||
<div className="flex items-center justify-between px-5 py-2 lg:py-3 lg:px-10">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
onClick={() => navigate("/")}
|
||||
title="Ir para Home"
|
||||
className="header-logo group cursor-pointer flex items-center space-x-2"
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Decoda Logo"
|
||||
className="h-[30px] transition-transform duration-200 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center"></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
{renderIcon("w-8 h-8")}
|
||||
<h5 className="text-brand-500 font-title font-semibold text-3xl">{titleToShow}</h5>
|
||||
{thumbnail && (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={titleToShow}
|
||||
className="h-8 w-8 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Botão menu flutuante - só aparece em telas pequenas */}
|
||||
<button
|
||||
className="fixed top-4 right-4 z-50 sm:hidden bg-white/80 rounded-full p-2 shadow-lg"
|
||||
aria-label="Abrir menu"
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<svg width="32" height="32" fill="none" viewBox="0 0 24 24">
|
||||
<rect x="4" y="7" width="16" height="2" rx="1" fill="#a21caf" />
|
||||
<rect x="4" y="11" width="16" height="2" rx="1" fill="#a21caf" />
|
||||
<rect x="4" y="15" width="16" height="2" rx="1" fill="#a21caf" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Overlay do menu mobile */}
|
||||
{isMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-70 flex flex-col items-end ">
|
||||
<div className="bg-gray-100 h-full p-5 w-[60%] flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-full flex justify-end">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="text-brand-500 text-3xl"
|
||||
aria-label="Fechar menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{type === "code" && <Code className="text-brand-500 size-6" />}
|
||||
{type === "blocks" && (
|
||||
<Puzzle className="text-brand-500 size-5" />
|
||||
)}
|
||||
<h5 className="text-bold font-title font-semibold text-2xl">{titleToShow}</h5>
|
||||
{thumbnail && (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={titleToShow}
|
||||
className="h-6 w-6 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
navigate("/");
|
||||
}}
|
||||
className="flex items-center gap-5 mt-10"
|
||||
>
|
||||
<ArrowLeft className="text-brand-500" />
|
||||
<span className="text-black font-bold text-base">Voltar</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Decoda"
|
||||
className="h-[83px]"
|
||||
onError={(e) => {
|
||||
e.target.style.display = "none";
|
||||
e.target.nextSibling.style.display = "inline";
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
GameNavBar.propTypes = {
|
||||
title: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
};
|
||||
61
app/src/components/game/ResizeHandle.jsx
Normal file
61
app/src/components/game/ResizeHandle.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @fileoverview React component for ResizeHandle.jsx
|
||||
*
|
||||
* @module components.game.ResizeHandle
|
||||
*/
|
||||
|
||||
|
||||
import { PanelResizeHandle } from "react-resizable-panels";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function ResizeHandle({
|
||||
direction = "horizontal",
|
||||
// English alias
|
||||
orientation,
|
||||
theme = "light",
|
||||
disabled = false,
|
||||
}) {
|
||||
const effectiveDirection = direction ?? orientation ?? "horizontal";
|
||||
const horizontal = effectiveDirection === "horizontal";
|
||||
const dark = theme === "dark";
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<PanelResizeHandle
|
||||
disabled={disabled}
|
||||
className={`
|
||||
group
|
||||
${horizontal ? "w-3" : "h-3"}
|
||||
${disabled ? "cursor-default" : horizontal ? "cursor-col-resize" : "cursor-row-resize"}
|
||||
flex items-center justify-center
|
||||
${dark ? "bg-gray-700 hover:bg-gray-600" : "bg-gray-200 hover:bg-gray-300"}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex ${horizontal ? "flex-col space-y-1" : "flex-row space-x-1"}
|
||||
items-center justify-center
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
|
||||
/>
|
||||
<span
|
||||
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
|
||||
/>
|
||||
<span
|
||||
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
|
||||
/>
|
||||
</div>
|
||||
</PanelResizeHandle>
|
||||
);
|
||||
}
|
||||
|
||||
ResizeHandle.propTypes = {
|
||||
direction: PropTypes.string,
|
||||
orientation: PropTypes.string,
|
||||
theme: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
178
app/src/components/game/SeletorDeFases.jsx
Normal file
178
app/src/components/game/SeletorDeFases.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview React component for SeletorDeFases.jsx
|
||||
*
|
||||
* @module components.game.SeletorDeFases
|
||||
*/
|
||||
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ConfirmacaoModal from "./ConfirmacaoModal";
|
||||
import { Lock, CheckCircle, Star, X } from "lucide-react";
|
||||
import { getUnlockedPhases } from "../../utils/phaseUtils";
|
||||
|
||||
export default function SeletorDeFases({
|
||||
isVisible,
|
||||
onClose,
|
||||
gameConfig,
|
||||
currentPhase,
|
||||
onChangePhase,
|
||||
onResetProgress,
|
||||
}) {
|
||||
const gameId = gameConfig.gameId;
|
||||
const totalPhases = gameConfig.fases.length;
|
||||
const storageKey = `${gameId}-completed-phases`;
|
||||
const [completedPhases, setCompletedPhases] = useState([]);
|
||||
const [showResetConfirmation, setShowResetConfirmation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const salvo = localStorage.getItem(storageKey);
|
||||
if (salvo) {
|
||||
try {
|
||||
const fases = JSON.parse(salvo);
|
||||
setCompletedPhases(fases);
|
||||
} catch {
|
||||
setCompletedPhases([]);
|
||||
}
|
||||
} else {
|
||||
setCompletedPhases([]);
|
||||
}
|
||||
}, [isVisible, storageKey]);
|
||||
|
||||
const unlockedPhases = getUnlockedPhases(completedPhases, totalPhases);
|
||||
|
||||
const selectPhase = (number) => {
|
||||
if (!unlockedPhases.includes(number)) return;
|
||||
if (onChangePhase) onChangePhase(number);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center text-black">
|
||||
<Star className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-title font-bold text-gray-800">
|
||||
Selecionar Fase - {gameConfig.gameName || "Jogo"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 cancel-icon bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: totalPhases }, (_, i) => {
|
||||
const phaseNumber = i + 1;
|
||||
const isUnlocked = unlockedPhases.includes(phaseNumber);
|
||||
const wasCompleted = completedPhases.includes(phaseNumber);
|
||||
const isCurrent = currentPhase === phaseNumber;
|
||||
const phaseData = gameConfig.fases[i];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phaseNumber}
|
||||
className={`bg-white/80 backdrop-blur-sm border border-white/30 rounded-xl shadow-lg p-4 cursor-pointer transition-all duration-200 border-2 ${
|
||||
isCurrent
|
||||
? "border-red-500 ring-2 ring-red-200"
|
||||
: isUnlocked
|
||||
? "border-transparent hover:border-red-300"
|
||||
: "border-gray-200 opacity-60 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={() => isUnlocked && selectPhase(phaseNumber)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-title font-bold ${
|
||||
wasCompleted
|
||||
? "bg-green-100 text-green-600"
|
||||
: !isUnlocked
|
||||
? "bg-gray-100 text-gray-400"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{wasCompleted ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : !isUnlocked ? (
|
||||
<Lock className="w-4 h-4" />
|
||||
) : (
|
||||
phaseNumber
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-red-600 text-white px-2 py-1 rounded-full font-medium">
|
||||
Atual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-title font-semibold text-gray-800 text-sm">
|
||||
Fase {phaseNumber}
|
||||
</h4>
|
||||
<h5 className="font-medium text-gray-700 text-sm">
|
||||
{phaseData.nome}
|
||||
</h5>
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{phaseData.descricao}
|
||||
</p>
|
||||
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700">
|
||||
{phaseData.dificuldade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-white/20 flex justify-center">
|
||||
<button
|
||||
className="bg-green-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-green-200"
|
||||
onClick={() => setShowResetConfirmation(true)}
|
||||
>
|
||||
Resetar TODO o progresso do jogo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmacaoModal
|
||||
isOpen={showResetConfirmation}
|
||||
onClose={() => setShowResetConfirmation(false)}
|
||||
onConfirm={() => {
|
||||
if (onResetProgress) onResetProgress();
|
||||
setShowResetConfirmation(false);
|
||||
onClose();
|
||||
}}
|
||||
title="Resetar progresso"
|
||||
message="Tem certeza que deseja apagar TODO o progresso e blocos salvos deste jogo? Esta ação não pode ser desfeita."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SeletorDeFases.propTypes = {
|
||||
isVisible: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
gameConfig: PropTypes.object.isRequired,
|
||||
onChangePhase: PropTypes.func,
|
||||
onResetProgress: PropTypes.func,
|
||||
};
|
||||
88
app/src/components/game/SucessoModal.jsx
Normal file
88
app/src/components/game/SucessoModal.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview React component for SucessoModal.jsx
|
||||
*
|
||||
* @module components.game.SucessoModal
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { ModalBase } from "./modal/ModalBase";
|
||||
import { ModalHeader } from "./modal/ModalHeader";
|
||||
import { CodeArea } from "./modal/CodeArea";
|
||||
import { FeedbackBox } from "./modal/FeedbackBox";
|
||||
|
||||
const SucessoModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onNextPhase,
|
||||
generatedCode,
|
||||
canGoNext,
|
||||
}) => {
|
||||
const handleNextPhase = () => {
|
||||
onClose();
|
||||
if (onNextPhase) {
|
||||
onNextPhase();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBase isOpen={isOpen} onClose={onClose}>
|
||||
<ModalHeader
|
||||
title="Parabéns!"
|
||||
subTitle="Veja o código que foi gerado"
|
||||
variant="success"
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
<CodeArea
|
||||
code={typeof generatedCode === "string" ? generatedCode : generatedCode?.codigo}
|
||||
variant="success"
|
||||
/>
|
||||
|
||||
<FeedbackBox title="O que aconteceu?" variant="success">
|
||||
<p>
|
||||
Os blocos que você conectou foram convertidos em código JavaScript
|
||||
real. Este é o mesmo tipo de código que os programadores usam para
|
||||
criar aplicações!
|
||||
</p>
|
||||
</FeedbackBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Rodapé (Botões de Ação) */}
|
||||
<div className="p-6 border-t border-gray-200 flex justify-between items-center bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-full font-medium transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
|
||||
{canGoNext && (
|
||||
<button
|
||||
onClick={handleNextPhase}
|
||||
className="flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md bg-green-100 hover:bg-green-200 text-black"
|
||||
>
|
||||
<span>Próxima Fase</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ModalBase>
|
||||
);
|
||||
};
|
||||
|
||||
SucessoModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onNextPhase: PropTypes.func,
|
||||
generatedCode: PropTypes.any,
|
||||
canGoNext: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default SucessoModal;
|
||||
332
app/src/components/game/editors/BlocklyEditor.jsx
Normal file
332
app/src/components/game/editors/BlocklyEditor.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* @fileoverview React component for BlocklyEditor.jsx
|
||||
*
|
||||
* @module components.game.editors.BlocklyEditor
|
||||
*/
|
||||
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import * as Blockly from "blockly/core";
|
||||
import Theme from "@blockly/theme-modern";
|
||||
import { javascriptGenerator } from "blockly/javascript";
|
||||
import { useGameState } from "../../../contexts/GameStateContext";
|
||||
import { validateBlocklyWorkspace } from "@/blockly/validation";
|
||||
import { useEditor } from "../../../contexts/EditorContext";
|
||||
import {
|
||||
loadWorkspace,
|
||||
createDebouncedSave,
|
||||
} from "../../../services/blockstorage";
|
||||
import { getCategoryIcon } from "./toolboxIcons";
|
||||
import { Hammer } from "lucide-react";
|
||||
import "./custom_category";
|
||||
import "./BlocklyEditor.mobile.css";
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="blockly-loading-spinner">
|
||||
<p className="blockly-loading-text">Carregando Editor...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BlocklyEditor = forwardRef(function BlocklyEditor(
|
||||
{ toolboxGenerator, debugSolutions = null, starterBlocks = null, starter = null },
|
||||
ref,
|
||||
) {
|
||||
const starterBlocksProp = starterBlocks ?? starter;
|
||||
const { registerExecutionFunction, onWorkspaceChange } = useGameState();
|
||||
const { gameConfig: editorGameConfig, currentPhase, editorData, updateEditorData, gameNameKey } =
|
||||
useEditor();
|
||||
|
||||
const hasSolution = debugSolutions && debugSolutions[currentPhase];
|
||||
|
||||
const handleLoadSolution = () => {
|
||||
if (!workspaceRef.current) {
|
||||
alert("Editor não está pronto");
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm("Deseja carregar a solução desta fase?")) {
|
||||
try {
|
||||
const solution = debugSolutions[currentPhase];
|
||||
workspaceRef.current.clear();
|
||||
Blockly.serialization.workspaces.load(solution, workspaceRef.current);
|
||||
} catch (error) {
|
||||
alert("Erro ao carregar a solução");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorGameConfig || !toolboxGenerator) return;
|
||||
|
||||
const phases = editorGameConfig.fases ?? editorGameConfig.phases; // support English alias
|
||||
const phaseConfig = phases?.[currentPhase - 1];
|
||||
const toolbox = toolboxGenerator(phaseConfig?.allowedBlocks);
|
||||
const max = phaseConfig?.maxBlocks;
|
||||
|
||||
updateEditorData({
|
||||
toolboxJson: toolbox,
|
||||
maxBlocks: max,
|
||||
});
|
||||
}, [editorGameConfig, currentPhase, toolboxGenerator, updateEditorData]);
|
||||
|
||||
const { toolboxJson, maxBlocks } = editorData;
|
||||
|
||||
const blocklyDiv = useRef(null);
|
||||
const workspaceRef = useRef(null);
|
||||
const [initialJson, setInitialJson] = useState(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitializedRef = useRef(false);
|
||||
const [currentBlockCount, setCurrentBlockCount] = useState(0);
|
||||
|
||||
const debouncedSave = useMemo(() => createDebouncedSave(1000), []);
|
||||
const stableToolboxJson = useMemo(() => toolboxJson, [toolboxJson]);
|
||||
|
||||
const updateCategoriesState = useCallback((limitReached) => {
|
||||
if (!workspaceRef.current) return;
|
||||
|
||||
const toolbox = workspaceRef.current.getToolbox();
|
||||
if (!toolbox) return;
|
||||
|
||||
const categories = toolbox.getToolboxItems();
|
||||
|
||||
categories.forEach((category) => {
|
||||
category.setDisabled(limitReached);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const workspaceChange = useCallback(() => {
|
||||
if (!workspaceRef.current) return;
|
||||
const blockCount = workspaceRef.current.getAllBlocks().length;
|
||||
setCurrentBlockCount(blockCount);
|
||||
|
||||
const limitReached =
|
||||
maxBlocks !== undefined &&
|
||||
maxBlocks !== Infinity &&
|
||||
maxBlocks <= blockCount;
|
||||
updateCategoriesState(limitReached);
|
||||
|
||||
if (onWorkspaceChange) {
|
||||
onWorkspaceChange(blockCount);
|
||||
}
|
||||
}, [onWorkspaceChange, maxBlocks, updateCategoriesState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateCategoriesState();
|
||||
}, [updateCategoriesState]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateAndValidateCode = () => {
|
||||
if (!workspaceRef.current) {
|
||||
return { codigo: null, workspace: null };
|
||||
}
|
||||
const validation = validateBlocklyWorkspace(workspaceRef.current, {
|
||||
allowMultipleTopBlocks: editorGameConfig?.allowMultipleTopBlocks ?? false,
|
||||
preferredStartBlocks: ["start", "when_run", "main"],
|
||||
});
|
||||
if (!validation.isValid) {
|
||||
return { codigo: null, workspace: null };
|
||||
}
|
||||
const codigo = javascriptGenerator.workspaceToCode(workspaceRef.current);
|
||||
return { codigo, workspace: workspaceRef.current };
|
||||
};
|
||||
registerExecutionFunction(generateAndValidateCode);
|
||||
|
||||
|
||||
return () => {
|
||||
registerExecutionFunction(null);
|
||||
};
|
||||
}, [registerExecutionFunction]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const loadedData = loadWorkspace(gameNameKey);
|
||||
setInitialJson(loadedData);
|
||||
setIsLoading(false);
|
||||
}, [gameNameKey]);
|
||||
|
||||
useImperativeHandle(ref, () => workspaceRef.current, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
isInitializedRef.current ||
|
||||
!blocklyDiv.current ||
|
||||
!stableToolboxJson
|
||||
) {
|
||||
return;
|
||||
}
|
||||
isInitializedRef.current = true;
|
||||
|
||||
const toolboxWithIcons = {
|
||||
...stableToolboxJson,
|
||||
contents: stableToolboxJson.contents.map((cat) => ({
|
||||
...cat,
|
||||
"css-icon": getCategoryIcon(cat.name),
|
||||
})),
|
||||
};
|
||||
|
||||
workspaceRef.current = Blockly.inject(blocklyDiv.current, {
|
||||
toolbox: toolboxWithIcons,
|
||||
trashcan: true,
|
||||
scrollbars: true,
|
||||
renderer: "zelos",
|
||||
theme: Theme,
|
||||
grid: { spacing: 25, length: 3, colour: "#ccc", snap: true },
|
||||
zoom: { controls: false, wheel: true, startScale: 0.7 },
|
||||
});
|
||||
|
||||
// Criar variáveis pré-definidas para o jogo Cripto
|
||||
if (editorGameConfig?.gameId === "cripto") {
|
||||
["entrada", "saida", "pos"].forEach((varName) => {
|
||||
const variableMap = workspaceRef.current.getVariableMap();
|
||||
if (!variableMap.getVariable(varName)) {
|
||||
workspaceRef.current.createVariable(varName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Carregar workspace: prioridade = localStorage > starterBlocks > vazio
|
||||
if (initialJson) {
|
||||
try {
|
||||
Blockly.serialization.workspaces.load(
|
||||
initialJson,
|
||||
workspaceRef.current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Falha ao carregar workspace do localStorage, limpando.",
|
||||
error,
|
||||
);
|
||||
workspaceRef.current.clear();
|
||||
}
|
||||
} else if (starterBlocksProp && starterBlocksProp[currentPhase]) {
|
||||
// Se não há nada salvo, carregar blocos iniciais da fase
|
||||
try {
|
||||
Blockly.serialization.workspaces.load(
|
||||
starterBlocksProp[currentPhase],
|
||||
workspaceRef.current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Falha ao carregar blocos iniciais.", error);
|
||||
}
|
||||
}
|
||||
|
||||
workspaceChange();
|
||||
|
||||
const listener = (event) => {
|
||||
if (!workspaceRef.current || event.isUiEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === Blockly.Events.BLOCK_CREATE) {
|
||||
const currentCount = workspaceRef.current.getAllBlocks().length;
|
||||
if (
|
||||
maxBlocks !== undefined &&
|
||||
maxBlocks !== Infinity &&
|
||||
currentCount > maxBlocks
|
||||
) {
|
||||
const blockToRemove = workspaceRef.current.getBlockById(
|
||||
event.blockId,
|
||||
);
|
||||
if (blockToRemove) {
|
||||
console.warn(
|
||||
`Limite de blocos atingido (${maxBlocks}). Removendo bloco extra: ${event.blockId}`,
|
||||
);
|
||||
|
||||
Blockly.Events.disable();
|
||||
blockToRemove.dispose(false);
|
||||
Blockly.Events.enable();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workspaceChange();
|
||||
const currentState = Blockly.serialization.workspaces.save(
|
||||
workspaceRef.current,
|
||||
);
|
||||
debouncedSave(gameNameKey, currentState);
|
||||
};
|
||||
|
||||
workspaceRef.current.addChangeListener(listener);
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (workspaceRef.current) {
|
||||
Blockly.svgResize(workspaceRef.current);
|
||||
}
|
||||
});
|
||||
observer.observe(blocklyDiv.current);
|
||||
|
||||
setTimeout(() => {
|
||||
updateCategoriesState();
|
||||
const bg = document.querySelector(".blocklyToolboxBackground");
|
||||
if (bg) {
|
||||
bg.setAttribute("fill", "none");
|
||||
bg.setAttribute("stroke", "none");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
if (workspaceRef.current) {
|
||||
workspaceRef.current.removeChangeListener(listener);
|
||||
try {
|
||||
workspaceRef.current.dispose();
|
||||
} catch (error) {
|
||||
console.warn("Erro ao fazer dispose do workspace:", error);
|
||||
}
|
||||
workspaceRef.current = null;
|
||||
}
|
||||
observer.disconnect();
|
||||
isInitializedRef.current = false;
|
||||
};
|
||||
}, [
|
||||
isLoading,
|
||||
initialJson,
|
||||
gameNameKey,
|
||||
stableToolboxJson,
|
||||
debouncedSave,
|
||||
workspaceChange,
|
||||
maxBlocks,
|
||||
updateCategoriesState,
|
||||
editorGameConfig,
|
||||
currentPhase,
|
||||
]);
|
||||
|
||||
if (isLoading || !toolboxJson) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{hasSolution && (
|
||||
<button
|
||||
onClick={handleLoadSolution}
|
||||
className="blockly-debug-solution-btn"
|
||||
title="Carregar solução (Debug)"
|
||||
>
|
||||
<Hammer className="w-5 h-5 text-yellow-500" />
|
||||
</button>
|
||||
)}
|
||||
<div ref={blocklyDiv} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default React.memo(BlocklyEditor);
|
||||
|
||||
BlocklyEditor.propTypes = {
|
||||
toolboxGenerator: PropTypes.func.isRequired,
|
||||
debugSolutions: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
|
||||
starterBlocks: PropTypes.object,
|
||||
starter: PropTypes.object,
|
||||
};
|
||||
237
app/src/components/game/editors/BlocklyEditor.mobile.css
Normal file
237
app/src/components/game/editors/BlocklyEditor.mobile.css
Normal file
@@ -0,0 +1,237 @@
|
||||
/* Loading Spinner Styles */
|
||||
.blockly-loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.blockly-loading-text {
|
||||
font-size: 18px;
|
||||
color: #555;
|
||||
}
|
||||
/* Estilos para destaque de blocos durante execução */
|
||||
.blocklySelected > .blocklyPath {
|
||||
stroke: #667eea !important;
|
||||
stroke-width: 1px !important;
|
||||
filter: drop-shadow(0 0 10px rgba(53, 2, 92, 1.7)) !important;
|
||||
}
|
||||
|
||||
.blocklySelected {
|
||||
z-index: 999 !important;
|
||||
}
|
||||
|
||||
/* ===== BLOCKLY CUSTOMIZATIONS ===== */
|
||||
.blocklyScrollbarVertical,
|
||||
.blocklyScrollbarHorizontal,
|
||||
.blocklyToolboxDiv .blocklyScrollbarVertical,
|
||||
.blocklyToolboxDiv .blocklyScrollbarHorizontal,
|
||||
.blocklyFlyout .blocklyScrollbarVertical,
|
||||
.blocklyFlyout .blocklyScrollbarHorizontal,
|
||||
.blocklyWorkspace .blocklyScrollbarVertical,
|
||||
.blocklyWorkspace .blocklyScrollbarHorizontal,
|
||||
.blocklyDiv .blocklyScrollbarVertical,
|
||||
.blocklyDiv .blocklyScrollbarHorizontal,
|
||||
.blocklyDiv * .blocklyScrollbarVertical,
|
||||
.blocklyDiv * .blocklyScrollbarHorizontal,
|
||||
.blocklyScrollbarHandle,
|
||||
.blocklyScrollbarKnob,
|
||||
.blocklyScrollbarBackground,
|
||||
div[class*="blockly"] .blocklyScrollbarVertical,
|
||||
div[class*="blockly"] .blocklyScrollbarHorizontal,
|
||||
svg[class*="blockly"] .blocklyScrollbarVertical,
|
||||
svg[class*="blockly"] .blocklyScrollbarHorizontal {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.blocklyWorkspace,
|
||||
.blocklyWorkspace svg,
|
||||
.blocklyToolboxDiv,
|
||||
.blocklyToolboxDiv *,
|
||||
.blocklyFlyout,
|
||||
.blocklyFlyout *,
|
||||
.blocklyDiv,
|
||||
.blocklyDiv *,
|
||||
.blocklyMainBackground,
|
||||
.blocklyTreeRoot {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.blocklyDiv {
|
||||
background-color: #f8f9fa;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.blocklyDiv .blocklyWorkspace {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.blocklyDiv .blocklyFlyout,
|
||||
.blocklyDiv .blocklyToolboxDiv {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.blocklyDiv:empty {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.fixed.z-50 {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
nav {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.blocklyDiv,
|
||||
.injectionDiv {
|
||||
z-index: 10 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.blocklyToolboxDiv {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.blocklyTreeLabel svg {
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ================================================================================================== */
|
||||
.blocklyToolboxCategoryLabel {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.blocklyToolboxContents {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.blocklyToolboxCategory {
|
||||
padding: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.2em;
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 10px;
|
||||
border: 5px solid #ffffff;
|
||||
}
|
||||
|
||||
.customIcon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.blocklyTreeRowContentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.blocklyToolboxCategory {
|
||||
height: initial;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.blocklyToolboxCategory {
|
||||
padding: 0px;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
width: 42px;
|
||||
height: 40px;
|
||||
/* Esconde o texto */
|
||||
}
|
||||
|
||||
.blocklyToolboxCategoryLabel {
|
||||
display: none;
|
||||
/* Some completamente */
|
||||
}
|
||||
|
||||
.blocklyTreeRowContentContainer {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
text-align: center !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.blocklyToolboxCategoryIcon {
|
||||
/* font-size: 32px !important; */
|
||||
margin: 0 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.blocklyText {
|
||||
font-size: small !important;
|
||||
}
|
||||
}
|
||||
|
||||
.blockly-debug-solution-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1000; /* Aumentado para ficar sobre tudo */
|
||||
padding: 0.5rem;
|
||||
background-color: white;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.blockly-debug-solution-btn:hover {
|
||||
background-color: #f3e8ff;
|
||||
}
|
||||
|
||||
.blockly-debug-solution-btn:active {
|
||||
background-color: #e9d5ff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blockly-debug-solution-btn {
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.blockly-debug-solution-btn svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.blockly-debug-solution-btn {
|
||||
top: 0.375rem;
|
||||
right: 0.375rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.blockly-debug-solution-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
201
app/src/components/game/editors/CodeEditor.jsx
Normal file
201
app/src/components/game/editors/CodeEditor.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @fileoverview React component for CodeEditor.jsx
|
||||
*
|
||||
* @module components.game.editors.CodeEditor
|
||||
*/
|
||||
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { indentWithTab, insertTab } from "@codemirror/commands";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { indentOnInput, indentUnit } from "@codemirror/language";
|
||||
import { useGameState } from "../../../contexts/GameStateContext";
|
||||
import { useEditor } from "../../../contexts/EditorContext";
|
||||
import {
|
||||
loadCode,
|
||||
createDebouncedCodeSave,
|
||||
} from "../../../services/codestorage";
|
||||
|
||||
const createGameCompletion = (gameConfig) => {
|
||||
return autocompletion({
|
||||
override: [
|
||||
(context) => {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null;
|
||||
|
||||
const allowedFunctions = gameConfig?.allowedFunctions || [];
|
||||
const functionDocs = gameConfig?.functionDocumentation || {};
|
||||
const allowedStructures = gameConfig?.allowedControlStructures || [];
|
||||
|
||||
const functionOptions = allowedFunctions.map((funcName) => {
|
||||
const doc = functionDocs[funcName] || {};
|
||||
return {
|
||||
label: funcName,
|
||||
type: "function",
|
||||
info: doc.description || `Função ${funcName}`,
|
||||
detail: doc.syntax || `${funcName}()`,
|
||||
apply: doc.example || `${funcName}()`,
|
||||
};
|
||||
});
|
||||
|
||||
const structureOptions = allowedStructures.map((structure) => {
|
||||
const templates = {
|
||||
if: "if (${condition}) {\n ${code}\n}",
|
||||
else: "else {\n ${code}\n}",
|
||||
while: "while (${condition}) {\n ${code}\n}",
|
||||
for: "for (${init}; ${condition}; ${update}) {\n ${code}\n}",
|
||||
var: "var ${name} = ${value};",
|
||||
function: "function ${name}() {\n ${code}\n}",
|
||||
};
|
||||
|
||||
return {
|
||||
label: structure,
|
||||
type: "keyword",
|
||||
info: `Estrutura de controle ${structure}`,
|
||||
detail: templates[structure] || structure,
|
||||
apply: templates[structure] || structure,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: [...functionOptions, ...structureOptions],
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export default function CodeEditor() {
|
||||
const {
|
||||
registerCodeEditorFunction,
|
||||
onCodeEditorChange,
|
||||
gameConfig,
|
||||
currentPhase,
|
||||
} = useGameState();
|
||||
const { gameNameKey } = useEditor();
|
||||
|
||||
const phases = gameConfig?.fases ?? gameConfig?.phases; // support English alias
|
||||
const phaseConfig = phases?.find((phase) => phase.id === currentPhase);
|
||||
const initialCode = phaseConfig?.initialCode || "// Digite seu código aqui";
|
||||
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const debouncedCodeSave = useMemo(() => createDebouncedCodeSave(1000), []);
|
||||
const codeStorageKey = `${gameNameKey}-code`;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
const savedCode = loadCode(codeStorageKey);
|
||||
if (savedCode !== null) {
|
||||
setCode(savedCode);
|
||||
} else {
|
||||
setCode(initialCode);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [codeStorageKey, initialCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const getCodeFromEditor = () => {
|
||||
return code;
|
||||
};
|
||||
|
||||
registerCodeEditorFunction(getCodeFromEditor);
|
||||
|
||||
|
||||
|
||||
return () => {
|
||||
registerCodeEditorFunction(null);
|
||||
};
|
||||
}, [code, registerCodeEditorFunction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
onCodeEditorChange(code);
|
||||
}
|
||||
}, [code, onCodeEditorChange, isLoading]);
|
||||
|
||||
const handleChange = (value) => {
|
||||
setCode(value);
|
||||
|
||||
if (!isLoading) {
|
||||
debouncedCodeSave(codeStorageKey, value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedCodeSave.cancel();
|
||||
};
|
||||
}, [debouncedCodeSave]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CodeMirror
|
||||
value={code}
|
||||
height="100%"
|
||||
extensions={[
|
||||
javascript(),
|
||||
createGameCompletion(gameConfig),
|
||||
indentOnInput(),
|
||||
indentUnit.of(" "), // 2 espaços para indentação
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
{
|
||||
key: "Enter",
|
||||
run: ({ state, dispatch }) => {
|
||||
const { from } = state.selection.main;
|
||||
const line = state.doc.lineAt(from);
|
||||
const lineText = line.text;
|
||||
const indent = lineText.match(/^\s*/)[0];
|
||||
|
||||
let newIndent = indent;
|
||||
if (lineText.trim().endsWith("{")) {
|
||||
newIndent += " ";
|
||||
}
|
||||
|
||||
dispatch(
|
||||
state.update({
|
||||
changes: {
|
||||
from,
|
||||
insert: "\n" + newIndent,
|
||||
},
|
||||
selection: { anchor: from + newIndent.length + 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
]}
|
||||
onChange={handleChange}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightSpecialChars: true,
|
||||
history: true,
|
||||
foldGutter: true,
|
||||
drawSelection: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CodeEditor.propTypes = {};
|
||||
79
app/src/components/game/editors/custom_category.js
Normal file
79
app/src/components/game/editors/custom_category.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @fileoverview Utility module for custom_category.js
|
||||
*
|
||||
* @module components.game.editors.custom_category
|
||||
*/
|
||||
|
||||
import * as Blockly from "blockly/core";
|
||||
|
||||
class CustomCategory extends Blockly.ToolboxCategory {
|
||||
constructor(categoryDef, toolbox, opt_parent) {
|
||||
super(categoryDef, toolbox, opt_parent);
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
// Agora o DOM está criado, pode alterar cor do texto e ícone
|
||||
const labelDom = this.rowDiv_.getElementsByClassName(
|
||||
"blocklyToolboxCategoryLabel",
|
||||
)[0];
|
||||
if (labelDom) {
|
||||
labelDom.style.color = "white";
|
||||
}
|
||||
if (this.iconDom_) {
|
||||
this.iconDom_.style.color = "white";
|
||||
}
|
||||
}
|
||||
|
||||
addColourBorder_(colour) {
|
||||
this.rowDiv_.style.backgroundColor = colour;
|
||||
}
|
||||
|
||||
setSelected(isSelected) {
|
||||
const labelDom = this.rowDiv_.getElementsByClassName(
|
||||
"blocklyToolboxCategoryLabel",
|
||||
)[0];
|
||||
if (isSelected) {
|
||||
this.rowDiv_.style.backgroundColor = "white";
|
||||
labelDom.style.color = this.colour_;
|
||||
this.iconDom_.style.color = this.colour_;
|
||||
} else {
|
||||
this.rowDiv_.style.backgroundColor = this.colour_;
|
||||
labelDom.style.color = "white";
|
||||
this.iconDom_.style.color = "white";
|
||||
}
|
||||
Blockly.utils.aria.setState(
|
||||
this.htmlDiv_,
|
||||
Blockly.utils.aria.State.SELECTED,
|
||||
isSelected,
|
||||
);
|
||||
}
|
||||
|
||||
createIconDom_() {
|
||||
const iconClass = this.toolboxItemDef_["css-icon"];
|
||||
if (iconClass) {
|
||||
const iconElement = document.createElement("i");
|
||||
iconElement.className = iconClass;
|
||||
iconElement.style.fontSize = "18px";
|
||||
iconElement.style.marginRight = "8px";
|
||||
|
||||
return iconElement;
|
||||
}
|
||||
|
||||
// fallback
|
||||
const iconImg = document.createElement("img");
|
||||
iconImg.src = "./logo_only.svg";
|
||||
iconImg.alt = "Blockly Logo";
|
||||
iconImg.width = 20;
|
||||
iconImg.height = 20;
|
||||
return iconImg;
|
||||
}
|
||||
}
|
||||
|
||||
Blockly.registry.register(
|
||||
Blockly.registry.Type.TOOLBOX_ITEM,
|
||||
Blockly.ToolboxCategory.registrationName,
|
||||
CustomCategory,
|
||||
true,
|
||||
);
|
||||
48
app/src/components/game/editors/toolboxIcons.js
Normal file
48
app/src/components/game/editors/toolboxIcons.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview Utility module for toolboxIcons.js
|
||||
*
|
||||
* @module components.game.editors.toolboxIcons
|
||||
*/
|
||||
|
||||
export function getCategoryIcon(name) {
|
||||
switch (name) {
|
||||
case "Movimento":
|
||||
return "fa fa-arrows-alt";
|
||||
case "Repetição":
|
||||
return "fa fa-refresh";
|
||||
case "Lógica":
|
||||
return "fa fa-code";
|
||||
case "Sensores":
|
||||
return "fa fa-eye";
|
||||
case "Cor":
|
||||
return "fa fa-paint-brush";
|
||||
case "Tempo":
|
||||
return "fa fa-clock";
|
||||
case "Semáforo Carros":
|
||||
return "fa fa-traffic-light";
|
||||
case "Semáforo Pedestre":
|
||||
return "fa fa-walking";
|
||||
case "Multimídia":
|
||||
return "fa fa-music";
|
||||
case "Matemática":
|
||||
return "fa fa-calculator";
|
||||
case "Condicionais":
|
||||
return "fa fa-check";
|
||||
case "Funções":
|
||||
return "fa fa-cogs";
|
||||
case "Cena":
|
||||
return "fa fa-flag";
|
||||
case "Eventos":
|
||||
return "fa fa-bell";
|
||||
case "Texto":
|
||||
return "fa fa-font";
|
||||
case "Listas":
|
||||
return "fa fa-list";
|
||||
case "Variáveis":
|
||||
return "fa fa-database";
|
||||
case "Caneta":
|
||||
return "fa fa-pencil-alt";
|
||||
default:
|
||||
return "fa fa-cube";
|
||||
}
|
||||
}
|
||||
44
app/src/components/game/modal/CodeArea.jsx
Normal file
44
app/src/components/game/modal/CodeArea.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @fileoverview React component for CodeArea.jsx
|
||||
*
|
||||
* @module components.game.modal.CodeArea
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Code } from "lucide-react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const CodeArea = ({
|
||||
code,
|
||||
title = "Código Gerado",
|
||||
variant = "success",
|
||||
}) => {
|
||||
const titleToShow = title;
|
||||
const textColors = {
|
||||
success: "text-green-400",
|
||||
failure: "text-red-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Code className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-title font-medium text-gray-900">{titleToShow}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg p-4 overflow-auto border border-gray-800">
|
||||
<pre
|
||||
className={`${textColors[variant]} text-sm font-mono whitespace-pre-wrap`}
|
||||
>
|
||||
{code || "Nenhum código disponível"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CodeArea.propTypes = {
|
||||
code: PropTypes.any,
|
||||
title: PropTypes.string,
|
||||
variant: PropTypes.string,
|
||||
};
|
||||
38
app/src/components/game/modal/FeedbackBox.jsx
Normal file
38
app/src/components/game/modal/FeedbackBox.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @fileoverview React component for FeedbackBox.jsx
|
||||
*
|
||||
* @module components.game.modal.FeedbackBox
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const FeedbackBox = ({ title, children, variant = "success" }) => {
|
||||
const titleToShow = title;
|
||||
const styles = {
|
||||
success: "bg-green-50 border-green-200 text-green-800",
|
||||
failure: "bg-amber-50 border-amber-200 text-amber-800",
|
||||
};
|
||||
|
||||
const titleStyles = {
|
||||
success: "text-green-900",
|
||||
failure: "text-amber-900",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mt-6 p-4 rounded-lg border ${styles[variant]}`}>
|
||||
<h4
|
||||
className={`font-medium mb-2 flex items-center gap-2 ${titleStyles[variant]}`}
|
||||
>
|
||||
{variant === "success" ? "💡" : "⚠️"} {titleToShow}
|
||||
</h4>
|
||||
<div className="text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeedbackBox.propTypes = {
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
variant: PropTypes.string,
|
||||
};
|
||||
31
app/src/components/game/modal/ModalBase.jsx
Normal file
31
app/src/components/game/modal/ModalBase.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @fileoverview React component for ModalBase.jsx
|
||||
*
|
||||
* @module components.game.modal.ModalBase
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const ModalBase = ({ isOpen, onClose, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose && onClose()}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ModalBase.propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
69
app/src/components/game/modal/ModalHeader.jsx
Normal file
69
app/src/components/game/modal/ModalHeader.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview React component for ModalHeader.jsx
|
||||
*
|
||||
* @module components.game.modal.ModalHeader
|
||||
*/
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { X, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const ModalHeader = ({
|
||||
title,
|
||||
subTitle,
|
||||
variant = "success",
|
||||
onClose,
|
||||
ariaCloseLabel,
|
||||
}) => {
|
||||
const subtitleText = subTitle;
|
||||
|
||||
// Mapeamento de estilos por variante
|
||||
const config = {
|
||||
success: {
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
Icon: CheckCircle,
|
||||
},
|
||||
failure: {
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
Icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const { bgColor, iconColor, Icon } = config[variant];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`w-10 h-10 ${bgColor} rounded-full flex items-center justify-center`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-title font-semibold text-gray-900">{title}</h2>
|
||||
<p className="text-sm font-title text-gray-600">{subtitleText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors cancel-icon"
|
||||
aria-label={ariaCloseLabel ?? "Fechar"}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ModalHeader.propTypes = {
|
||||
title: PropTypes.string,
|
||||
subTitle: PropTypes.string,
|
||||
variant: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
ariaCloseLabel: PropTypes.string,
|
||||
};
|
||||
111
app/src/components/letramento/AtividadeRuntimeFrame.jsx
Normal file
111
app/src/components/letramento/AtividadeRuntimeFrame.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { letramentoEventBus, LETRAMENTO_EVENTS } from '../../utils/letramentoEvents';
|
||||
|
||||
// Allowed event types from the iframe activity.
|
||||
// The HTML activity sends: window.parent.postMessage({ type: 'started' | 'running' | 'success' | 'failure' | 'completed', ...payload }, '*')
|
||||
const ALLOWED_TYPES = new Set(Object.values(LETRAMENTO_EVENTS));
|
||||
|
||||
function generateChannelToken() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `ltr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function resolveHtmlFile(htmlFile) {
|
||||
if (/^https?:\/\//.test(htmlFile) || /^file:\/\//.test(htmlFile)) {
|
||||
return htmlFile;
|
||||
}
|
||||
|
||||
if (window.location.protocol === 'file:') {
|
||||
const relativePath = htmlFile.startsWith('/') ? htmlFile.slice(1) : htmlFile;
|
||||
return new URL(relativePath, window.location.href).toString();
|
||||
}
|
||||
|
||||
return htmlFile.startsWith('/') ? htmlFile : `/${htmlFile}`;
|
||||
}
|
||||
|
||||
function buildIframeSrc(htmlFile, channelToken) {
|
||||
const [base, existingHash = ''] = htmlFile.split('#');
|
||||
const resolvedBase = resolveHtmlFile(base);
|
||||
const hashParams = new URLSearchParams(existingHash);
|
||||
hashParams.set('channelToken', channelToken);
|
||||
return `${resolvedBase}#${hashParams.toString()}`;
|
||||
}
|
||||
|
||||
export default function AtividadeRuntimeFrame({ htmlFile, atividadeId, reloadToken = 0, className = '' }) {
|
||||
const iframeRef = useRef(null);
|
||||
const channelToken = useMemo(
|
||||
() => generateChannelToken(),
|
||||
[atividadeId, htmlFile, reloadToken],
|
||||
);
|
||||
const iframeSrc = useMemo(
|
||||
() => buildIframeSrc(htmlFile, channelToken),
|
||||
[htmlFile, channelToken],
|
||||
);
|
||||
|
||||
const focusRuntime = () => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
iframe.focus();
|
||||
iframe.contentWindow?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow;
|
||||
if (!iframeWindow || event.source !== iframeWindow) return;
|
||||
|
||||
// Accept only messages from the same origin or from sandboxed iframes.
|
||||
// Sandboxed iframes (sandbox="allow-scripts" without allow-same-origin) always
|
||||
// report event.origin as the string "null" — we allow that explicitly.
|
||||
const allowedOrigin =
|
||||
event.origin === window.location.origin || event.origin === 'null';
|
||||
if (!allowedOrigin) return;
|
||||
|
||||
const { type, token, ...payload } = event.data ?? {};
|
||||
if (token !== channelToken) return;
|
||||
if (!type || !ALLOWED_TYPES.has(type)) return;
|
||||
|
||||
letramentoEventBus.dispatch(type, { atividadeId, ...payload });
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [atividadeId, channelToken]);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus right after mount/switch so keyboard-only users can start immediately.
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
focusRuntime();
|
||||
window.setTimeout(focusRuntime, 50);
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [atividadeId, htmlFile, reloadToken]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={`${atividadeId}:${reloadToken}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
title="Atividade de Letramento Digital"
|
||||
className={`w-full h-full border-0 ${className}`}
|
||||
tabIndex={-1}
|
||||
onLoad={focusRuntime}
|
||||
// allow-same-origin enables file:// subresource loading in Electron builds.
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
AtividadeRuntimeFrame.propTypes = {
|
||||
htmlFile: PropTypes.string.isRequired,
|
||||
atividadeId: PropTypes.string.isRequired,
|
||||
reloadToken: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
98
app/src/components/letramento/AtividadeStatusModal.jsx
Normal file
98
app/src/components/letramento/AtividadeStatusModal.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CheckCircle, XCircle, RotateCcw, ArrowRight } from 'lucide-react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ATIVIDADE_STATES } from '../../contexts/LetramentoStateContext';
|
||||
|
||||
export default function AtividadeStatusModal({ status, isOpen, onTryAgain, onDismiss, nextAtividadeId, onNextAtividade }) {
|
||||
const isSuccess = status === ATIVIDADE_STATES.SUCCESS || status === ATIVIDADE_STATES.COMPLETED;
|
||||
const isFailure = status === ATIVIDADE_STATES.FAILURE;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || (!isSuccess && !isFailure)) return undefined;
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && isSuccess && nextAtividadeId && onNextAtividade) {
|
||||
event.preventDefault();
|
||||
onNextAtividade();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFailure, isOpen, isSuccess, nextAtividadeId, onDismiss, onNextAtividade]);
|
||||
|
||||
if (!isOpen || (!isSuccess && !isFailure)) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div
|
||||
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl w-full max-w-md flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header — identical to SeletorDeFases */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-500'}`}>
|
||||
{isSuccess ? <CheckCircle className="w-5 h-5" /> : <XCircle className="w-5 h-5" />}
|
||||
</div>
|
||||
<h3 className="text-xl font-title font-bold text-gray-800">
|
||||
{isSuccess ? 'Atividade concluída' : 'Tente novamente'}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-gray-700 text-base">
|
||||
{isSuccess
|
||||
? 'Você concluiu esta atividade com sucesso.'
|
||||
: 'Não desanime. Você consegue tentar outra vez.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer — identical to SeletorDeFases */}
|
||||
<div className="p-4 border-t border-white/20 flex justify-center gap-3 flex-wrap">
|
||||
{isFailure && (
|
||||
<button
|
||||
onClick={onTryAgain}
|
||||
className="flex items-center gap-2 bg-brand-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-brand-200"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Tentar Novamente
|
||||
</button>
|
||||
)}
|
||||
{isSuccess && nextAtividadeId && onNextAtividade && (
|
||||
<button
|
||||
onClick={onNextAtividade}
|
||||
className="flex items-center gap-2 bg-green-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-green-200"
|
||||
>
|
||||
Próxima Atividade
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="bg-gray-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-gray-200"
|
||||
>
|
||||
Voltar à Lista
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AtividadeStatusModal.propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onTryAgain: PropTypes.func.isRequired,
|
||||
onDismiss: PropTypes.func.isRequired,
|
||||
nextAtividadeId: PropTypes.string,
|
||||
onNextAtividade: PropTypes.func,
|
||||
};
|
||||
204
app/src/components/letramento/AtividadesSidebar.jsx
Normal file
204
app/src/components/letramento/AtividadesSidebar.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { listarAtividadesPorCategoria } from '../../atividades/letramento/letramentoRegistry';
|
||||
import { getCompletedPhases } from '../../services/letramentoStorage';
|
||||
import { getUnlockedAtividades } from '../../utils/phaseUtils';
|
||||
import { MousePointer, Keyboard, Lock, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const CATEGORIA_UI = {
|
||||
mouse: {
|
||||
Icon: MousePointer,
|
||||
headerClass: 'bg-red-50',
|
||||
iconWrapClass: 'bg-red-600',
|
||||
currentClass: 'bg-red-50 border-red-500 ring-red-200',
|
||||
currentBadgeClass: 'bg-red-600 text-white',
|
||||
completedClass: 'bg-green-50 border-green-300 hover:border-green-400 hover:shadow',
|
||||
completedBadgeClass: 'bg-green-100 text-green-600',
|
||||
hoverBorderClass: 'hover:border-red-300',
|
||||
},
|
||||
teclado: {
|
||||
Icon: Keyboard,
|
||||
headerClass: 'bg-brand-50',
|
||||
iconWrapClass: 'bg-brand-600',
|
||||
currentClass: 'bg-brand-50 border-brand-500 ring-brand-200',
|
||||
currentBadgeClass: 'bg-brand-600 text-white',
|
||||
completedClass: 'bg-brand-50 border-brand-300 hover:border-brand-400 hover:shadow',
|
||||
completedBadgeClass: 'bg-brand-100 text-brand-700',
|
||||
hoverBorderClass: 'hover:border-brand-300',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar com lista de atividades da mesma categoria
|
||||
* Mostra progresso e permite navegação
|
||||
*/
|
||||
export default function AtividadesSidebar({ atividadeAtual, categoria, onChangeAtividade, currentAtividadeCompleted = false }) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const atividades = listarAtividadesPorCategoria(categoria);
|
||||
const categoriaUi = CATEGORIA_UI[categoria] ?? CATEGORIA_UI.mouse;
|
||||
|
||||
// Busca array de atividades completadas (similar ao SeletorDeFases)
|
||||
const completedPhases = getCompletedPhases(categoria);
|
||||
const optimisticCompleted = currentAtividadeCompleted
|
||||
? Array.from(new Set([...completedPhases, atividadeAtual]))
|
||||
: completedPhases;
|
||||
|
||||
const unlockedPhases = getUnlockedAtividades(optimisticCompleted, atividades);
|
||||
|
||||
const atividadesDesbloqueadas = new Set(unlockedPhases);
|
||||
|
||||
const handleAtividadeClick = (atividadeId) => {
|
||||
if (atividadesDesbloqueadas.has(atividadeId) && atividadeId !== atividadeAtual) {
|
||||
onChangeAtividade(atividadeId);
|
||||
}
|
||||
};
|
||||
|
||||
const CategoriaIcon = categoriaUi.Icon;
|
||||
|
||||
return (
|
||||
<div className={`bg-white/95 backdrop-blur-sm border-r border-gray-200 flex flex-col overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
|
||||
{/* Header da categoria */}
|
||||
<div className={`p-4 border-b border-gray-200 ${categoriaUi.headerClass}`}>
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${categoriaUi.iconWrapClass}`}>
|
||||
<CategoriaIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 capitalize">{categoria}</h3>
|
||||
<p className="text-xs text-gray-600">{atividades.length} atividades</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCollapsed && (
|
||||
<div className="flex justify-center">
|
||||
<div className={`p-2 rounded-lg ${categoriaUi.iconWrapClass}`}>
|
||||
<CategoriaIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de atividades */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{atividades.map((atividade, index) => {
|
||||
const isAtual = atividade.id === atividadeAtual;
|
||||
const isDesbloqueada = atividadesDesbloqueadas.has(atividade.id);
|
||||
const isCompletada = optimisticCompleted.includes(atividade.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={atividade.id}
|
||||
onClick={() => handleAtividadeClick(atividade.id)}
|
||||
disabled={!isDesbloqueada}
|
||||
className={`
|
||||
w-full mb-2 rounded-lg border-2 transition-all
|
||||
${isCollapsed ? 'p-2' : 'p-3'}
|
||||
${isAtual
|
||||
? `${categoriaUi.currentClass} ring-2 shadow-md`
|
||||
: isCompletada
|
||||
? categoriaUi.completedClass
|
||||
: isDesbloqueada
|
||||
? `bg-white border-gray-200 ${categoriaUi.hoverBorderClass} hover:shadow`
|
||||
: 'bg-gray-50 border-gray-200 cursor-not-allowed opacity-60'
|
||||
}
|
||||
`}
|
||||
title={isCollapsed ? `${atividade.titulo} - ${atividade.descricao}` : ''}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// Versão colapsada - apenas ícone/número
|
||||
<div className="flex justify-center">
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
|
||||
${isAtual
|
||||
? categoriaUi.currentBadgeClass
|
||||
: isCompletada
|
||||
? categoriaUi.completedBadgeClass
|
||||
: isDesbloqueada
|
||||
? 'bg-gray-200 text-gray-700'
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
}
|
||||
`}>
|
||||
{isCompletada ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : !isDesbloqueada ? (
|
||||
<Lock className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Versão expandida - conteúdo completo
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Número ou status icon */}
|
||||
<div className={`
|
||||
flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
|
||||
${isAtual
|
||||
? categoriaUi.currentBadgeClass
|
||||
: isCompletada
|
||||
? categoriaUi.completedBadgeClass
|
||||
: isDesbloqueada
|
||||
? 'bg-gray-200 text-gray-700'
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
}
|
||||
`}>
|
||||
{isCompletada ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : !isDesbloqueada ? (
|
||||
<Lock className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Título e descrição */}
|
||||
<div className="flex-1 text-left">
|
||||
<h4 className={`
|
||||
text-sm font-semibold mb-1
|
||||
${isAtual ? 'text-gray-800' : isDesbloqueada ? 'text-gray-800' : 'text-gray-400'}
|
||||
`}>
|
||||
{atividade.titulo}
|
||||
</h4>
|
||||
<p className={`
|
||||
text-xs line-clamp-2
|
||||
${isAtual ? 'text-gray-600' : isDesbloqueada ? 'text-gray-600' : 'text-gray-400'}
|
||||
`}>
|
||||
{atividade.descricao}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer com botão de colapsar */}
|
||||
<div className="border-t border-gray-200 p-3 bg-gray-50">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white hover:bg-gray-100 border border-gray-300 rounded-lg transition-colors"
|
||||
title={isCollapsed ? 'Expandir menu' : 'Recolher menu'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-sm text-gray-600 font-medium">Recolher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AtividadesSidebar.propTypes = {
|
||||
atividadeAtual: PropTypes.string.isRequired,
|
||||
categoria: PropTypes.string.isRequired,
|
||||
onChangeAtividade: PropTypes.func.isRequired,
|
||||
currentAtividadeCompleted: PropTypes.bool,
|
||||
};
|
||||
29
app/src/components/letramento/LetramentoNavBar.jsx
Normal file
29
app/src/components/letramento/LetramentoNavBar.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Monitor, X } from 'lucide-react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function LetramentoNavBar({ titulo, onClose }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600">
|
||||
<Monitor className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-title font-bold text-gray-800">
|
||||
{titulo}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 cancel-icon bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
|
||||
aria-label="Fechar atividade"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LetramentoNavBar.propTypes = {
|
||||
titulo: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
63
app/src/components/letramento/TrilhaPassos.jsx
Normal file
63
app/src/components/letramento/TrilhaPassos.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// Displays a horizontal step trail — Windows wizard style.
|
||||
// currentStep is 1-based (matches the step numbers sent by the activity).
|
||||
// 0 = not started.
|
||||
export default function TrilhaPassos({ passos, currentStep, completed }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0 border-b border-white/20 px-6 py-3 overflow-x-auto">
|
||||
{passos.map((passo, idx) => {
|
||||
const stepNum = idx + 1;
|
||||
const isDone = completed || stepNum < currentStep;
|
||||
const isActive = !completed && stepNum === currentStep;
|
||||
|
||||
return (
|
||||
<div key={passo.id} className="flex items-center">
|
||||
{/* step circle */}
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div
|
||||
className={[
|
||||
'w-9 h-9 rounded-full flex items-center justify-center border-2 font-bold text-sm transition-colors',
|
||||
isDone
|
||||
? 'bg-green-600 border-green-600 text-white'
|
||||
: isActive
|
||||
? 'bg-brand-500 border-brand-500 text-white'
|
||||
: 'bg-white border-gray-400 text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{isDone ? <Check className="w-4 h-4" /> : stepNum}
|
||||
</div>
|
||||
<span
|
||||
className={[
|
||||
'text-xs text-center leading-tight whitespace-nowrap',
|
||||
isDone ? 'text-green-700 font-medium' : isActive ? 'text-brand-500 font-semibold' : 'text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{passo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* connector */}
|
||||
{idx < passos.length - 1 && (
|
||||
<div
|
||||
className={[
|
||||
'h-0.5 w-8 mx-1 mb-5 flex-shrink-0 transition-colors',
|
||||
isDone ? 'bg-green-500' : 'bg-gray-300',
|
||||
].join(' ')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TrilhaPassos.propTypes = {
|
||||
passos: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.number, label: PropTypes.string })).isRequired,
|
||||
currentStep: PropTypes.number.isRequired,
|
||||
completed: PropTypes.bool,
|
||||
};
|
||||
|
||||
TrilhaPassos.defaultProps = { completed: false };
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AtividadeRuntimeFrame from '../AtividadeRuntimeFrame';
|
||||
import { letramentoEventBus } from '../../../utils/letramentoEvents';
|
||||
|
||||
function getRenderedIframe() {
|
||||
return screen.getByTitle('Atividade de Letramento Digital');
|
||||
}
|
||||
|
||||
function getChannelToken(iframe) {
|
||||
const iframeUrl = new URL(iframe.getAttribute('src'), 'http://localhost');
|
||||
return new URLSearchParams(iframeUrl.hash.slice(1)).get('channelToken');
|
||||
}
|
||||
|
||||
describe('AtividadeRuntimeFrame', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('despacha eventos validos enviados pelo iframe com token correto', () => {
|
||||
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
|
||||
|
||||
render(
|
||||
<AtividadeRuntimeFrame
|
||||
htmlFile="/atividades/letramento/teste/index.html"
|
||||
atividadeId="atividade-segura"
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = getRenderedIframe();
|
||||
const token = getChannelToken(iframe);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'completed', token, score: 100 },
|
||||
origin: 'null',
|
||||
source: iframe.contentWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('completed', {
|
||||
atividadeId: 'atividade-segura',
|
||||
score: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignora mensagens com token invalido', () => {
|
||||
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
|
||||
|
||||
render(
|
||||
<AtividadeRuntimeFrame
|
||||
htmlFile="/atividades/letramento/teste/index.html"
|
||||
atividadeId="atividade-segura"
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = getRenderedIframe();
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'completed', token: 'token-invalido', score: 100 },
|
||||
origin: 'null',
|
||||
source: iframe.contentWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignora mensagens vindas de outra source', () => {
|
||||
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
|
||||
|
||||
render(
|
||||
<AtividadeRuntimeFrame
|
||||
htmlFile="/atividades/letramento/teste/index.html"
|
||||
atividadeId="atividade-segura"
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = getRenderedIframe();
|
||||
const token = getChannelToken(iframe);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'completed', token, score: 100 },
|
||||
origin: 'null',
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user