diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..187c53d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# Version control +.git/ +.gitignore + +# Node.js +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# Development files +.editorconfig +.eslintrc +.stylelintrc +.prettierrc +.vscode/ +.idea/ +*.swp +*.swo + +# Documentation +docs/ +README.md +LICENSE +CHANGELOG.md +*.md + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Test files +__tests__/ +test/ +tests/ +coverage/ + +# Build artifacts +dist/ +build/ + +# Environment files +.env +.env.* +*.env + +# Project specific files +screenshots/ +labels.example +virt-game-timer.service \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb125ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Dependency directories +node_modules/ +jspm_packages/ + +# Build outputs +dist/ +build/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Editor directories and files +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.docker/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 20c4f09..496b523 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,9 @@ FROM nginx:alpine # Remove the default Nginx static files RUN rm -rf /usr/share/nginx/html/* -# Copy all your app's files into the Nginx directory -COPY . /usr/share/nginx/html +# Copy public directory contents into the Nginx directory +COPY public/ /usr/share/nginx/html/ +COPY src/ /usr/share/nginx/html/src/ # Expose port 80 EXPOSE 80 diff --git a/README.md b/README.md index a6c7396..bfb4fb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,31 @@ # Game Timer -Multi-player chess timer with carousel navigation +Multi-player game-timer timer with carousel navigation + +## Project Structure + +``` +game-timer/ +├── docs/ # Documentation and project resources +│ └── screenshots/ # Application screenshots +├── public/ # Static assets and public-facing resources +│ ├── css/ # CSS stylesheets +│ ├── images/ # Images used by the application +│ ├── icons/ # App icons for PWA +│ ├── audio/ # Audio files +│ ├── index.html # Main HTML entry point +│ ├── manifest.json # PWA manifest +│ └── sw.js # Service Worker +├── src/ # Source code +│ └── js/ # JavaScript files +│ ├── core/ # Core application logic +│ ├── ui/ # UI-related code +│ ├── services/ # External services integration +│ └── utils/ # Utility functions +├── Dockerfile # Docker container definition +├── .dockerignore # Files to exclude from Docker build +└── package.json # Project metadata and dependencies +``` # PWA Containerized Deployment @@ -23,7 +48,7 @@ git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git cd game-timer ``` -### 2. Build the docker image +### 2. Build the Docker image From the repository root, run the following command to build your Docker image: @@ -56,7 +81,7 @@ docker logs game-timer-container After running the container, open your web browser and navigate to: ```bash -http://localhost +http://localhost:8080 ``` ### 5. Terminate @@ -65,4 +90,14 @@ To stop your running game-timer-container, use: ```bash docker stop game-timer-container -``` \ No newline at end of file +``` + +## Development + +For local development without Docker, you can use: + +```bash +npm run dev +``` + +This will start a local development server and open the application in your browser. \ No newline at end of file diff --git a/app.js b/app.js deleted file mode 100644 index 661ded5..0000000 --- a/app.js +++ /dev/null @@ -1,446 +0,0 @@ -// app.js - Main Application Orchestrator -import * as config from './config.js'; -import * as state from './state.js'; -import * as ui from './ui.js'; -import * as timer from './timer.js'; -import camera from './camera.js'; // Default export -import audioManager from './audio.js'; -import * as pushFlic from './pushFlicIntegration.js'; - -// --- Core Game Actions --- - -function startGame() { - if (state.getPlayers().length < 2) { - alert('You need at least 2 players to start.'); - return; - } - if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) { - state.setGameState(config.GAME_STATES.RUNNING); - audioManager.play('gameStart'); - timer.startTimer(); - ui.updateGameButton(); - ui.renderPlayers(); // Ensure active timer styling is applied - } -} - -function pauseGame() { - if (state.getGameState() === config.GAME_STATES.RUNNING) { - state.setGameState(config.GAME_STATES.PAUSED); - audioManager.play('gamePause'); - timer.stopTimer(); - ui.updateGameButton(); - ui.renderPlayers(); // Ensure active timer styling is removed - } -} - -function resumeGame() { - if (state.getGameState() === config.GAME_STATES.PAUSED) { - // Check if there's actually a player with time left - if (state.findNextPlayerWithTime() === -1) { - console.log("Cannot resume, no players have time left."); - // Optionally set state to OVER here - handleGameOver(); - return; - } - state.setGameState(config.GAME_STATES.RUNNING); - audioManager.play('gameResume'); - timer.startTimer(); - ui.updateGameButton(); - ui.renderPlayers(); // Ensure active timer styling is applied - } -} - -function togglePauseResume() { - const currentGameState = state.getGameState(); - if (currentGameState === config.GAME_STATES.RUNNING) { - pauseGame(); - } else if (currentGameState === config.GAME_STATES.PAUSED) { - resumeGame(); - } else if (currentGameState === config.GAME_STATES.SETUP) { - startGame(); - } else if (currentGameState === config.GAME_STATES.OVER) { - resetGame(); // Or just go back to setup? Let's reset. - startGame(); - } -} - -function switchToPlayer(index) { - if (index >= 0 && index < state.getPlayers().length) { - const previousIndex = state.getCurrentPlayerIndex(); - if(index !== previousIndex) { - state.setCurrentPlayerIndex(index); - audioManager.play('playerSwitch'); - ui.renderPlayers(); // Update UI immediately - - // If the game is running, restart the timer for the new player - // The timer interval callback will handle the decrementing - if (state.getGameState() === config.GAME_STATES.RUNNING) { - timer.startTimer(); // This clears the old interval and starts anew - } - } - } -} - -function nextPlayer() { - const currentGameState = state.getGameState(); - let newIndex = -1; - - if (currentGameState === config.GAME_STATES.RUNNING) { - newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time - } else { - // Allow cycling through all players if not running - const playerCount = state.getPlayers().length; - if(playerCount > 0) { - newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount; - } - } - - if (newIndex !== -1) { - switchToPlayer(newIndex); - } else if (currentGameState === config.GAME_STATES.RUNNING) { - console.log("NextPlayer: No other player has time remaining."); - // Optionally handle game over immediately? Timer logic should catch this too. - } -} - -function previousPlayer() { - const currentGameState = state.getGameState(); - let newIndex = -1; - - if (currentGameState === config.GAME_STATES.RUNNING) { - newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time - } else { - // Allow cycling through all players if not running - const playerCount = state.getPlayers().length; - if (playerCount > 0) { - newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount; - } - } - - if (newIndex !== -1) { - switchToPlayer(newIndex); - } else if (currentGameState === config.GAME_STATES.RUNNING) { - console.log("PreviousPlayer: No other player has time remaining."); - } -} - - -function handleGameOver() { - state.setGameState(config.GAME_STATES.OVER); - audioManager.play('gameOver'); - timer.stopTimer(); // Ensure timer is stopped - ui.updateGameButton(); - ui.renderPlayers(); // Update to show final state -} - -function resetGame() { - timer.stopTimer(); // Stop timer if running/paused - state.resetPlayersTime(); - state.setGameState(config.GAME_STATES.SETUP); - state.setCurrentPlayerIndex(0); // Go back to first player - audioManager.play('buttonClick'); // Or a specific reset sound? - ui.updateGameButton(); - ui.renderPlayers(); -} - -function fullResetApp() { - timer.stopTimer(); - state.resetToDefaults(); - audioManager.play('gameOver'); // Use game over sound for full reset - ui.hideResetModal(); - ui.updateGameButton(); - ui.renderPlayers(); -} - -// --- Event Handlers --- - -function handleGameButtonClick() { - audioManager.play('buttonClick'); - togglePauseResume(); -} - -function handleSetupButtonClick() { - audioManager.play('buttonClick'); - if (state.getGameState() === config.GAME_STATES.RUNNING) { - alert('Please pause the game before editing players.'); - return; - } - const currentPlayer = state.getCurrentPlayer(); - if (!currentPlayer) { - console.warn("Edit clicked but no current player?"); - return; // Or show Add Player modal? - } - camera.stopStream(); // Ensure camera is off before opening modal - ui.showPlayerModal(false, currentPlayer); -} - -function handleAddPlayerButtonClick() { - audioManager.play('buttonClick'); - if (state.getGameState() === config.GAME_STATES.RUNNING) { - alert('Please pause the game before adding players.'); - return; - } - camera.stopStream(); // Ensure camera is off before opening modal - ui.showPlayerModal(true); -} - -function handleResetButtonClick() { - audioManager.play('buttonClick'); - if (state.getGameState() === config.GAME_STATES.RUNNING) { - alert('Please pause the game before resetting.'); - return; - } - ui.showResetModal(); -} - -function handlePlayerFormSubmit(event) { - event.preventDefault(); - audioManager.play('buttonClick'); - - const name = ui.elements.playerNameInput.value.trim(); - const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10); - let remainingTimeSeconds = 0; // Default - const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player'; - const currentGameState = state.getGameState(); - - if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) { - alert('Please enter a valid name and positive time.'); - return; - } - - // Get remaining time ONLY if editing and game is paused/over - if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) { - const remainingTimeString = ui.elements.playerRemainingTimeInput.value; - const parsedSeconds = ui.parseTimeString(remainingTimeString); - if (parsedSeconds === null) { // Check if parsing failed - alert('Please enter remaining time in MM:SS format (e.g., 05:30).'); - return; - } - remainingTimeSeconds = parsedSeconds; - // Validate remaining time against total time? Optional. - if (remainingTimeSeconds > timeInMinutes * 60) { - alert('Remaining time cannot be greater than the total time.'); - return; - } - } else { - // For new players or when editing in setup, remaining time matches total time - remainingTimeSeconds = timeInMinutes * 60; - } - - let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null; - const imageFile = ui.elements.playerImageInput.files[0]; - - const saveAction = (finalImageData) => { - if (isNewPlayer) { - state.addPlayer(name, timeInMinutes, finalImageData); - audioManager.play('playerAdded'); - } else { - const playerIndex = state.getCurrentPlayerIndex(); - // Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one - const imageArg = finalImageData !== null ? finalImageData : (imageFile || ui.elements.playerImageInput.dataset.capturedImage ? finalImageData : undefined); - state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg); - audioManager.play('playerEdited'); - } - ui.hidePlayerModal(); - ui.renderPlayers(); - ui.updateGameButton(); // Update in case player count changed for setup state - camera.stopStream(); // Ensure camera is stopped - }; - - if (!imageDataUrl && imageFile) { - // Handle file upload: Read file as Data URL - const reader = new FileReader(); - reader.onload = (e) => saveAction(e.target.result); - reader.onerror = (e) => { - console.error("Error reading image file:", e); - alert("Error processing image file."); - }; - reader.readAsDataURL(imageFile); - } else { - // Handle captured image or no image change - const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image; - // If imageDataUrl has content (from camera), use it. - // If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later). - // If it's a new player and no image, pass null. - saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage)); - } -} - -function handlePlayerModalCancel() { - audioManager.play('buttonClick'); - ui.hidePlayerModal(); - camera.stopStream(); // Make sure camera turns off -} - -function handleDeletePlayer() { - audioManager.play('buttonClick'); - const success = state.deletePlayer(state.getCurrentPlayerIndex()); - if (success) { - audioManager.play('playerDeleted'); - ui.hidePlayerModal(); - ui.renderPlayers(); - ui.updateGameButton(); // Update in case player count dropped below 2 - } else { - alert('Cannot delete player. Minimum of 2 players required.'); - } - camera.stopStream(); -} - -function handleResetConfirm() { - audioManager.play('buttonClick'); - fullResetApp(); -} - -function handleResetCancel() { - audioManager.play('buttonClick'); - ui.hideResetModal(); -} - -function handleCameraButtonClick(event) { - event.preventDefault(); // Prevent form submission if inside form - audioManager.play('buttonClick'); - camera.open(); // Open the camera interface -} - -// --- Timer Callbacks --- - -function handleTimerTick() { - // Timer module already updated the state, just need to redraw UI - ui.renderPlayers(); -} - -function handlePlayerSwitchOnTimer(newPlayerIndex) { - // Timer detected current player ran out, found next player - console.log(`Timer switching to player index: ${newPlayerIndex}`); - switchToPlayer(newPlayerIndex); - // Sound is handled in switchToPlayer -} - -// --- Camera Callback --- -function handleCameraCapture(imageDataUrl) { - console.log("Image captured"); - ui.updateImagePreviewFromDataUrl(imageDataUrl); - // Camera module already closed the camera UI -} - -// --- Service Worker and PWA Setup --- - -function setupServiceWorker() { - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js') - .then(registration => { - console.log('ServiceWorker registered successfully.'); - - // Listen for messages FROM the Service Worker (e.g., Flic actions) - navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); - - // Initialize Flic integration (which will try to subscribe) - initFlic(); - }) - .catch(error => { - console.error('ServiceWorker registration failed:', error); - }); - }); - // Listen for SW controller changes - navigator.serviceWorker.addEventListener('controllerchange', () => { - console.log('Service Worker controller changed, potentially updated.'); - // window.location.reload(); // Consider prompting user to reload - }); - - } else { - console.warn('ServiceWorker not supported.'); - } -} - -function handleServiceWorkerMessage(event) { - console.log('[App] Message received from Service Worker:', event.data); - if (event.data?.type === 'flic-action') { - const { action, button, timestamp, batteryLevel } = event.data; - pushFlic.handleFlicAction(action, button, timestamp, batteryLevel); - } -} - -// --- Flic Integration Setup --- -function initFlic() { - // Define what happens for each Flic button action - const flicActionHandlers = { - [config.FLIC_ACTIONS.SINGLE_CLICK]: nextPlayer, - [config.FLIC_ACTIONS.DOUBLE_CLICK]: previousPlayer, // Map double-click to previous player - [config.FLIC_ACTIONS.HOLD]: togglePauseResume, - }; - pushFlic.initPushFlic(flicActionHandlers); -} - - -// --- Initialization --- - -function initialize() { - console.log("Initializing Game Timer App..."); - - // 1. Load saved state or defaults - state.loadData(); - - // 2. Initialize UI (pass carousel swipe handler) - ui.initUI({ - onCarouselSwipe: (direction) => { - if (direction > 0) nextPlayer(); else previousPlayer(); - } - }); - - // 3. Initialize Timer (pass callbacks for UI updates/state changes) - timer.initTimer({ - onTimerTick: handleTimerTick, - onPlayerSwitch: handlePlayerSwitchOnTimer, - onGameOver: handleGameOver - }); - - // 4. Initialize Camera (pass elements and capture callback) - camera.init( - { // Pass relevant DOM elements - cameraContainer: ui.elements.cameraContainer, - cameraView: ui.elements.cameraView, - cameraCanvas: ui.elements.cameraCanvas, - cameraCaptureButton: ui.elements.cameraCaptureButton, - cameraCancelButton: ui.elements.cameraCancelButton - }, - { // Pass options/callbacks - onCapture: handleCameraCapture - } - ); - - // 5. Set up UI Event Listeners that trigger actions - ui.elements.gameButton.addEventListener('click', handleGameButtonClick); - ui.elements.setupButton.addEventListener('click', handleSetupButtonClick); - ui.elements.addPlayerButton.addEventListener('click', handleAddPlayerButtonClick); - ui.elements.resetButton.addEventListener('click', handleResetButtonClick); - ui.elements.playerForm.addEventListener('submit', handlePlayerFormSubmit); - ui.elements.cancelButton.addEventListener('click', handlePlayerModalCancel); - ui.elements.deletePlayerButton.addEventListener('click', handleDeletePlayer); - ui.elements.resetConfirmButton.addEventListener('click', handleResetConfirm); - ui.elements.resetCancelButton.addEventListener('click', handleResetCancel); - ui.elements.cameraButton.addEventListener('click', handleCameraButtonClick); - - // 6. Setup Service Worker (which also initializes Flic) - setupServiceWorker(); - - // 7. Initial UI Update based on loaded state - ui.renderPlayers(); - ui.updateGameButton(); - - // 8. Resume timer if state loaded as 'running' (e.g., after refresh) - // Note: This might be undesirable. Usually, a refresh should pause. - // Consider forcing state to 'paused' or 'setup' on load? - // For now, let's reset running state to paused on load. - if (state.getGameState() === config.GAME_STATES.RUNNING) { - console.log("Game was running on load, setting to paused."); - state.setGameState(config.GAME_STATES.PAUSED); - ui.updateGameButton(); - ui.renderPlayers(); - } - - console.log("App Initialized."); -} - -// --- Start the application --- -initialize(); \ No newline at end of file diff --git a/labels.example b/docs/examples/labels.example similarity index 100% rename from labels.example rename to docs/examples/labels.example diff --git a/virt-game-timer.service b/docs/examples/virt-game-timer.service similarity index 100% rename from virt-game-timer.service rename to docs/examples/virt-game-timer.service diff --git a/screenshots/screenshot1.png b/docs/screenshots/screenshot1.png similarity index 100% rename from screenshots/screenshot1.png rename to docs/screenshots/screenshot1.png diff --git a/screenshots/screenshot2.png b/docs/screenshots/screenshot2.png similarity index 100% rename from screenshots/screenshot2.png rename to docs/screenshots/screenshot2.png diff --git a/package.json b/package.json index 9c50cda..2309717 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "countdown", "version": "1.0.0", "description": "Multi-player chess timer with carousel navigation", - "main": "app.js", + "main": "src/js/app.js", "scripts": { "start": "docker run -d -p 8080:80 --name game-timer-container game-timer:latest", "stop": "docker stop game-timer-container && docker rm game-timer-container", @@ -10,7 +10,7 @@ "rebuild": "npm run stop || true && npm run build && npm run start", "logs": "docker logs game-timer-container", "status": "docker ps | grep game-timer-container", - "dev": "cd /usr/share/nginx/html && python -m http.server 8000", + "dev": "npx http-server . -o /public", "clean": "docker system prune -f" }, "keywords": [ diff --git a/styles.css b/public/css/styles.css similarity index 100% rename from styles.css rename to public/css/styles.css diff --git a/favicon.ico b/public/favicon.ico similarity index 100% rename from favicon.ico rename to public/favicon.ico diff --git a/icons/android-chrome-192x192.png b/public/icons/android-chrome-192x192.png similarity index 100% rename from icons/android-chrome-192x192.png rename to public/icons/android-chrome-192x192.png diff --git a/icons/android-chrome-512x512.png b/public/icons/android-chrome-512x512.png similarity index 100% rename from icons/android-chrome-512x512.png rename to public/icons/android-chrome-512x512.png diff --git a/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png similarity index 100% rename from icons/apple-touch-icon.png rename to public/icons/apple-touch-icon.png diff --git a/icons/favicon-16x16.png b/public/icons/favicon-16x16.png similarity index 100% rename from icons/favicon-16x16.png rename to public/icons/favicon-16x16.png diff --git a/icons/favicon-32x32.png b/public/icons/favicon-32x32.png similarity index 100% rename from icons/favicon-32x32.png rename to public/icons/favicon-32x32.png diff --git a/index.html b/public/index.html similarity index 97% rename from index.html rename to public/index.html index 4407f35..cd7f9a2 100644 --- a/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ Game Timer - + @@ -102,10 +102,10 @@ - +