From e58c1070d67cf0ab6ccb39d9985bf8564dbea708 Mon Sep 17 00:00:00 2001 From: fccview Date: Thu, 1 Jan 2026 08:18:17 +0000 Subject: [PATCH] add snake and continue with consistent styling --- .../Cronjobs/CronJobList.tsx | 2 +- .../FeatureComponents/Games/SnakeGame.tsx | 431 ++++++++++++++++++ .../Layout/TabbedInterface.tsx | 4 +- .../Modals/DeleteScriptModal.tsx | 1 - .../Modals/DeleteTaskModal.tsx | 1 - .../FeatureComponents/Modals/LogsModal.tsx | 6 +- .../Modals/RestoreBackupModal.tsx | 6 +- .../FeatureComponents/Modals/ScriptModal.tsx | 2 +- .../Scripts/BashSnippetHelper.tsx | 5 +- .../Scripts/CronExpressionHelper.tsx | 4 +- .../Scripts/ScriptsManager.tsx | 4 +- .../GlobalComponents/UIElements/Modal.tsx | 2 +- app/_translations/en.json | 16 + app/_translations/it.json | 17 + app/globals.css | 6 +- app/not-found.tsx | 111 +++++ app/page.tsx | 2 +- public/webtui/theme-catppuccin.css | 2 +- 18 files changed, 596 insertions(+), 26 deletions(-) create mode 100644 app/_components/FeatureComponents/Games/SnakeGame.tsx create mode 100644 app/not-found.tsx diff --git a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx index ff605b5..27a03b9 100644 --- a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx +++ b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx @@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { onNewTaskClick={() => setIsNewCronModalOpen(true)} /> ) : ( -
+
{loadedSettings ? ( filteredJobs.map((job) => minimalMode ? ( diff --git a/app/_components/FeatureComponents/Games/SnakeGame.tsx b/app/_components/FeatureComponents/Games/SnakeGame.tsx new file mode 100644 index 0000000..f994e3b --- /dev/null +++ b/app/_components/FeatureComponents/Games/SnakeGame.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { ArrowUpIcon, ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowClockwiseIcon, PlayIcon, PauseIcon } from "@phosphor-icons/react"; + +interface Position { + x: number; + y: number; +} + +type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT"; + +const GRID_SIZE = 20; +const INITIAL_SNAKE: Position[] = [ + { x: 10, y: 10 }, + { x: 9, y: 10 }, + { x: 8, y: 10 }, +]; +const INITIAL_DIRECTION: Direction = "RIGHT"; +const GAME_SPEED = 150; + +export const SnakeGame = () => { + const t = useTranslations("notFound"); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [snake, setSnake] = useState(INITIAL_SNAKE); + const [direction, setDirection] = useState(INITIAL_DIRECTION); + const [food, setFood] = useState({ x: 15, y: 15 }); + const [gameOver, setGameOver] = useState(false); + const [gameStarted, setGameStarted] = useState(false); + const [score, setScore] = useState(0); + const [highScore, setHighScore] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const [colors, setColors] = useState({ snake: "#00ff00", food: "#ff0000", grid: "#333333" }); + const [cellSize, setCellSize] = useState(20); + + const directionRef = useRef(INITIAL_DIRECTION); + const gameLoopRef = useRef(null); + + useEffect(() => { + const savedHighScore = localStorage.getItem("snakeHighScore"); + if (savedHighScore) { + setHighScore(parseInt(savedHighScore)); + } + + const updateColors = () => { + const theme = document.documentElement.getAttribute("data-webtui-theme"); + if (theme === "catppuccin-mocha") { + setColors({ + snake: "#9ca0b0", + food: "#f38ba8", + grid: "#313244", + }); + } else { + setColors({ + snake: "#313244", + food: "#d20f39", + grid: "#9ca0b0", + }); + } + }; + + updateColors(); + + const observer = new MutationObserver(updateColors); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-webtui-theme"], + }); + + const updateCellSize = () => { + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const maxCanvasSize = Math.min(containerWidth - 32, 400); + const newCellSize = Math.floor(maxCanvasSize / GRID_SIZE); + setCellSize(newCellSize); + } + }; + + updateCellSize(); + window.addEventListener("resize", updateCellSize); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateCellSize); + }; + }, []); + + const generateFood = useCallback((): Position => { + let newFood: Position; + do { + newFood = { + x: Math.floor(Math.random() * GRID_SIZE), + y: Math.floor(Math.random() * GRID_SIZE), + }; + } while (snake.some((segment) => segment.x === newFood.x && segment.y === newFood.y)); + return newFood; + }, [snake]); + + const resetGame = useCallback(() => { + setSnake(INITIAL_SNAKE); + setDirection(INITIAL_DIRECTION); + directionRef.current = INITIAL_DIRECTION; + setFood(generateFood()); + setGameOver(false); + setGameStarted(true); + setScore(0); + setIsPaused(false); + }, [generateFood]); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const theme = document.documentElement.getAttribute("data-webtui-theme"); + const bgColor = theme === "catppuccin-mocha" ? "#1e1e2e" : "#eff1f5"; + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = colors.grid; + ctx.lineWidth = 1; + for (let i = 0; i <= GRID_SIZE; i++) { + ctx.beginPath(); + ctx.moveTo(i * cellSize, 0); + ctx.lineTo(i * cellSize, GRID_SIZE * cellSize); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, i * cellSize); + ctx.lineTo(GRID_SIZE * cellSize, i * cellSize); + ctx.stroke(); + } + + snake.forEach((segment) => { + ctx.fillStyle = colors.snake; + ctx.fillRect( + segment.x * cellSize + 1, + segment.y * cellSize + 1, + cellSize - 2, + cellSize - 2 + ); + }); + + ctx.fillStyle = colors.food; + ctx.fillRect( + food.x * cellSize + 1, + food.y * cellSize + 1, + cellSize - 2, + cellSize - 2 + ); + }, [snake, food, colors, cellSize]); + + useEffect(() => { + draw(); + }, [draw]); + + const moveSnake = useCallback(() => { + if (gameOver || !gameStarted || isPaused) return; + + setSnake((prevSnake) => { + const head = prevSnake[0]; + const newHead: Position = { ...head }; + + switch (directionRef.current) { + case "UP": + newHead.y -= 1; + break; + case "DOWN": + newHead.y += 1; + break; + case "LEFT": + newHead.x -= 1; + break; + case "RIGHT": + newHead.x += 1; + break; + } + + if ( + newHead.x < 0 || + newHead.x >= GRID_SIZE || + newHead.y < 0 || + newHead.y >= GRID_SIZE || + prevSnake.some((segment) => segment.x === newHead.x && segment.y === newHead.y) + ) { + setGameOver(true); + setGameStarted(false); + return prevSnake; + } + + const newSnake = [newHead, ...prevSnake]; + + if (newHead.x === food.x && newHead.y === food.y) { + setScore((prev) => { + const newScore = prev + 10; + if (newScore > highScore) { + setHighScore(newScore); + localStorage.setItem("snakeHighScore", newScore.toString()); + } + return newScore; + }); + setFood(generateFood()); + } else { + newSnake.pop(); + } + + return newSnake; + }); + }, [gameOver, gameStarted, isPaused, food, highScore, generateFood]); + + useEffect(() => { + if (gameStarted && !gameOver && !isPaused) { + gameLoopRef.current = setInterval(moveSnake, GAME_SPEED); + } + + return () => { + if (gameLoopRef.current) { + clearInterval(gameLoopRef.current); + } + }; + }, [gameStarted, gameOver, isPaused, moveSnake]); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.code === "Space") { + e.preventDefault(); + if (gameOver) { + resetGame(); + } else if (!gameStarted) { + setGameStarted(true); + } + return; + } + + if (e.code === "KeyP") { + e.preventDefault(); + if (gameStarted && !gameOver) { + setIsPaused((prev) => !prev); + } + return; + } + + if (!gameStarted || gameOver || isPaused) return; + + let newDirection: Direction | null = null; + + switch (e.key) { + case "ArrowUp": + if (directionRef.current !== "DOWN") { + newDirection = "UP"; + } + break; + case "ArrowDown": + if (directionRef.current !== "UP") { + newDirection = "DOWN"; + } + break; + case "ArrowLeft": + if (directionRef.current !== "RIGHT") { + newDirection = "LEFT"; + } + break; + case "ArrowRight": + if (directionRef.current !== "LEFT") { + newDirection = "RIGHT"; + } + break; + } + + if (newDirection) { + e.preventDefault(); + directionRef.current = newDirection; + setDirection(newDirection); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [gameOver, gameStarted, isPaused, resetGame]); + + const handleTouchMove = (dir: Direction) => { + if (!gameStarted) { + setGameStarted(true); + directionRef.current = dir; + setDirection(dir); + return; + } + if (gameOver || isPaused) return; + + let canMove = false; + + switch (dir) { + case "UP": + canMove = directionRef.current !== "DOWN"; + break; + case "DOWN": + canMove = directionRef.current !== "UP"; + break; + case "LEFT": + canMove = directionRef.current !== "RIGHT"; + break; + case "RIGHT": + canMove = directionRef.current !== "LEFT"; + break; + } + + if (canMove) { + directionRef.current = dir; + setDirection(dir); + } + }; + + const handleCanvasClick = () => { + if (gameOver) { + resetGame(); + } else if (!gameStarted) { + setGameStarted(true); + } + }; + + return ( +
+
+
+
+ {t("score")}: {score} +
+
+ {t("highScore")}: {highScore} +
+
+ +
+ + + {!gameStarted && !gameOver && ( +
+
+

{t("pressToStart")}

+

{t("pauseGame")}

+
+
+ )} + + {gameOver && ( +
+
+

+ {t("gameOver")} +

+

+ {t("score")}: {score} +

+

{t("pressToRestart")}

+
+
+ )} + + {isPaused && ( +
+
+

+ {t("paused")} +

+
+
+ )} +
+ +
+
+ +
+ + + +
+ +
+
+ +
+

{t("useArrowKeys")}

+

{t("tapToMove")}

+
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Layout/TabbedInterface.tsx b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx index bf28798..d5d5cbd 100644 --- a/app/_components/FeatureComponents/Layout/TabbedInterface.tsx +++ b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx @@ -35,7 +35,7 @@ export const TabbedInterface = ({ > {t("cronjobs.cronJobs")} - + {cronJobs.length} @@ -48,7 +48,7 @@ export const TabbedInterface = ({ > {t("scripts.scripts")} - + {scripts.length} diff --git a/app/_components/FeatureComponents/Modals/DeleteScriptModal.tsx b/app/_components/FeatureComponents/Modals/DeleteScriptModal.tsx index d56e078..d3b83cf 100644 --- a/app/_components/FeatureComponents/Modals/DeleteScriptModal.tsx +++ b/app/_components/FeatureComponents/Modals/DeleteScriptModal.tsx @@ -78,7 +78,6 @@ export const DeleteScriptModal = ({
) : selectedLog ? ( -
+                
                   {logContent}
                 
) : ( diff --git a/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx b/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx index 9f09ed8..e0d7fbd 100644 --- a/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx +++ b/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx @@ -120,7 +120,7 @@ export const RestoreBackupModal = ({

{t("cronjobs.noBackupsFound")}

) : ( -
+
{backups.map((backup) => (
-
+
{isDraft && onClearDraft && (
@@ -209,7 +209,6 @@ export const BashSnippetHelper = ({ variant="outline" size="sm" onClick={() => handleCopy(snippet)} - className="h-6 w-8 p-0 text-xs" > {copiedId === snippet.id ? ( @@ -222,7 +221,7 @@ export const BashSnippetHelper = ({ variant="default" size="sm" onClick={() => handleInsert(snippet)} - className="h-6 px-3 text-xs flex-1" + className="flex-1" > Insert diff --git a/app/_components/FeatureComponents/Scripts/CronExpressionHelper.tsx b/app/_components/FeatureComponents/Scripts/CronExpressionHelper.tsx index 6ffcaa8..00208d0 100644 --- a/app/_components/FeatureComponents/Scripts/CronExpressionHelper.tsx +++ b/app/_components/FeatureComponents/Scripts/CronExpressionHelper.tsx @@ -97,7 +97,7 @@ export const CronExpressionHelper = ({
{explanation && ( -
+
@@ -166,7 +166,7 @@ export const CronExpressionHelper = ({ setPatternSearch(e.target.value)} - placeholder="MagnifyingGlassIcon patterns..." + placeholder="Search patterns..." className="pl-9" />
diff --git a/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx b/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx index f337b8d..3bd788b 100644 --- a/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx +++ b/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx @@ -206,7 +206,7 @@ export const ScriptsManager = ({
-
+
@@ -331,7 +331,7 @@ export const ScriptsManager = ({ setSelectedScript(script); setIsDeleteModalOpen(true); }} - className="btn-destructive h-8 px-3" + className="h-8 px-3" title="Delete script" aria-label="Delete script" > diff --git a/app/_components/GlobalComponents/UIElements/Modal.tsx b/app/_components/GlobalComponents/UIElements/Modal.tsx index 110a752..4136981 100644 --- a/app/_components/GlobalComponents/UIElements/Modal.tsx +++ b/app/_components/GlobalComponents/UIElements/Modal.tsx @@ -59,7 +59,7 @@ export const Modal = ({ } }} > -
+

{title}

{showCloseButton && (