commit 960e9e0ff885115c7106007051121dc7ce317208 Author: Devon Campbell Date: Sun Oct 19 15:33:51 2025 -0400 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa2470e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# Use the official lightweight Nginx image +FROM nginx:alpine + +# Remove the default nginx website content +RUN rm -rf /usr/share/nginx/html/* + +# Copy our app's build files to the nginx web root directory +COPY public_build/ /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e5b3909 --- /dev/null +++ b/build.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# --- Configuration --- +IMAGE_NAME="workit" +TAG="latest" +OUTPUT_FILE="workit-amd64.tar" +TARGET_PLATFORM="linux/amd64" +BUILD_DIR="public_build" + +# --- Script Start --- +echo "--- Preparing build directory ---" +# Clean up previous build directory if it exists +rm -rf ${BUILD_DIR} +# Create a temporary build directory to avoid modifying source files +cp -r public ${BUILD_DIR} + +# Step 1: Run the Node.js script to inline audio files. +# This modifies the app.js inside the temporary BUILD_DIR. +echo "" +node scripts/inline-audio.js + +# Check if the node script was successful. +if [ $? -ne 0 ]; then + echo "!!! Audio inlining failed. Aborting build." + rm -rf ${BUILD_DIR} # Clean up + exit 1 +fi + +echo "" +echo "--- Starting Docker image build for ${TARGET_PLATFORM} ---" + +# Step 2: Build the multi-platform image using the modified files. +docker buildx build --platform ${TARGET_PLATFORM} -t ${IMAGE_NAME}:${TAG} --load . + +# Check if the build command was successful. +if [ $? -ne 0 ]; then + echo "!!! Docker build failed. Aborting script." + rm -rf ${BUILD_DIR} # Clean up + exit 1 +fi + +# Step 3: Clean up the temporary build directory. +echo "" +echo "--- Cleaning up build directory ---" +rm -rf ${BUILD_DIR} + +echo "" +echo "--- Build successful. Saving image to ${OUTPUT_FILE} ---" + +# Step 4: Save the newly created image to a .tar archive. +docker save -o ${OUTPUT_FILE} ${IMAGE_NAME}:${TAG} + +if [ $? -ne 0 ]; then + echo "!!! Failed to save Docker image. Aborting script." + exit 1 +fi + +echo "" +echo "✅ Done!" +echo "Image has been saved to: ${OUTPUT_FILE}" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4c6d2b9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + workout-app: + build: . + container_name: workout-app + ports: + - "8080:80" # Access the app at http://:8080 + restart: unless-stopped + volumes: + # This is optional. If you want to edit files live without rebuilding, + # you can mount the public directory. For production, it's better to build the image. + # - ./public:/usr/share/nginx/html:ro diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..bdb125f --- /dev/null +++ b/public/app.js @@ -0,0 +1,542 @@ +document.addEventListener('DOMContentLoaded', async () => { + + // --- AUDIO DATA (INLINED BY BUILD SCRIPT) --- + // __SOUND_DATA_PLACEHOLDER__ + + const app = { + // --- STATE --- + workouts: [], + db: null, + currentView: 'home', + currentWorkoutId: null, + currentExerciseIndex: 0, + runnerInterval: null, + wakeLock: null, + userHasInteracted: false, + timerState: 'idle', + timeRemainingOnPause: 0, + currentRepOnPause: 1, + + // Web Audio API state + audioContext: null, soundBuffers: {}, soundsLoaded: false, audioLoading: false, + runnerViewEl: null, + + // --- INITIALIZATION & CORE --- + async init() { + await this.initDB(); + await this.loadWorkouts(); + this.attachEventListeners(); + this.registerServiceWorker(); + this.render(); + }, + + registerServiceWorker() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/service-worker.js'); + } + }, + + async initDB() { + this.db = await idb.openDB('WorkItDB', 1, { + upgrade(db) { + db.createObjectStore('workouts', { keyPath: 'id' }); + }, + }); + console.log("Database initialized."); + }, + + async loadWorkouts() { + const oldData = localStorage.getItem('workouts'); + if (oldData) { + console.log("Found old data in localStorage. Migrating to IndexedDB..."); + try { + const oldWorkouts = JSON.parse(oldData); + if (Array.isArray(oldWorkouts)) { + const tx = this.db.transaction('workouts', 'readwrite'); + await Promise.all(oldWorkouts.map(workout => tx.store.put(workout))); + await tx.done; + localStorage.removeItem('workouts'); + console.log("Migration successful."); + } + } catch (e) { + console.error("Failed to parse or migrate old data:", e); + } + } + this.workouts = await this.db.getAll('workouts'); + }, + + async saveWorkouts() { + const tx = this.db.transaction('workouts', 'readwrite'); + await tx.store.clear(); + await Promise.all(this.workouts.map(workout => tx.store.put(workout))); + await tx.done; + }, + + async acquireWakeLock() { + if ('wakeLock' in navigator) try { this.wakeLock = await navigator.wakeLock.request('screen'); } catch (e) {} + }, + + async releaseWakeLock() { + if (this.wakeLock) { + await this.wakeLock.release(); + this.wakeLock = null; + } + }, + + async initAudio(triggerElement) { + if (this.audioLoading || this.soundsLoaded) return; + this.audioLoading = true; + if (triggerElement) { triggerElement.disabled = true; triggerElement.textContent = 'Loading...'; } + if (!this.audioContext) this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + await this.loadSounds(); + this.audioLoading = false; + if (triggerElement) { + triggerElement.disabled = false; + if (triggerElement.dataset.action === 'start') { triggerElement.innerHTML = '▶'; } + else { triggerElement.textContent = 'Start Exercise'; } + } + }, + + async loadSounds() { + if (typeof soundData === 'undefined') { console.error("Sound data not defined."); return; } + const soundNames = Object.keys(soundData); + const loadPromises = soundNames.map(async (name) => { + try { + const response = await fetch(soundData[name]); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); + this.soundBuffers[name] = audioBuffer; + } catch (e) { console.error(`Failed to decode sound: ${name}`, e); } + }); + await Promise.all(loadPromises); + this.soundsLoaded = true; + console.log("All inlined sounds decoded."); + }, + + playSound(name) { + if (!this.audioContext || !this.soundBuffers[name]) return; + if (this.audioContext.state === 'suspended') this.audioContext.resume(); + const source = this.audioContext.createBufferSource(); + source.buffer = this.soundBuffers[name]; + source.connect(this.audioContext.destination); + source.start(0); + }, + + render() { + const container = document.getElementById('app-container'); + container.innerHTML = ''; + if (this.runnerViewEl) { this.runnerViewEl.remove(); this.runnerViewEl = null; } + if (this.currentView === 'home') this.renderHome(container); + if (this.currentView === 'editor') this.renderEditor(container); + }, + + renderHome(container) { + let content = `

My Workouts

`; + if (Array.isArray(this.workouts)) { + this.workouts.forEach(workout => { + content += ` +
+

${workout.name}

+
+ + +
+
+ `; + }); + } + content += ``; + container.innerHTML = content; + }, + + renderEditor(container) { + const workout = this.workouts.find(w => w.id === this.currentWorkoutId) || + { id: Date.now(), name: 'New Workout', exercises: [] }; + let exercisesHtml = ''; + workout.exercises.forEach((ex, index) => { + const showDelay = ex.reps && ex.time; + exercisesHtml += ` +
+
+

${ex.name || 'New Exercise'}

+
+ + + +
+
+

${ex.description || ''}

Reps: ${ex.reps || 'N/A'} | Time: ${ex.time || 'N/A'}s | Delay: ${ex.delay || 'N/A'}s

+
+
+
+
+
+
+ `; + }); + const content = ` +
+

Exercises

${exercisesHtml}
+ + +
+ + +
+ + `; + container.innerHTML = content; + }, + + attachEventListeners() { + document.body.addEventListener('click', async (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + + if (this.audioContext && this.audioContext.state === 'suspended') await this.audioContext.resume(); + const action = target.dataset.action; + if (!this.soundsLoaded && (action === 'start' || action === 'get-ready')) await this.initAudio(target); + + const workoutEl = target.closest('.workout-list-item'); + const workoutId = workoutEl ? parseInt(workoutEl.dataset.id) : this.currentWorkoutId; + + const actions = { + 'new-workout': () => this.newWorkout(), + 'edit': () => this.editWorkout(workoutId), + 'delete': () => this.deleteWorkout(workoutId), + 'clone': () => this.cloneWorkout(workoutId), + 'start': () => this.startRunner(workoutId), + 'save-workout': () => this.saveWorkoutFromEditor(), + 'back-home': () => this.goHome(), + 'add-exercise': () => this.addExercise(), + 'delete-exercise': () => this.deleteExercise(parseInt(target.closest('.exercise-card').dataset.index)), + 'move-up': () => this.moveExercise(parseInt(target.closest('.exercise-card').dataset.index), -1), + 'move-down': () => this.moveExercise(parseInt(target.closest('.exercise-card').dataset.index), 1), + 'stop-runner': () => this.stopRunner(), + 'next-exercise': () => this.changeExercise(1), + 'prev-exercise': () => this.changeExercise(-1), + 'get-ready': () => this.runGetReadyCountdown(), + 'pause-timer': () => this.pauseTimer(), + 'resume-timer': () => this.resumeTimer(), + 'reset-timer': () => this.resetTimer(), + 'clone-workout': () => this.cloneWorkout(workoutId), + 'delete-workout': () => this.deleteWorkout(workoutId), + 'finish-workout': () => this.renderCompletionScreen(), + }; + if (actions[action]) await actions[action](); + }); + + document.body.addEventListener('input', e => { + if (this.currentView !== 'editor') return; + const field = e.target.dataset.field; + if (!field) return; + const exerciseCard = e.target.closest('.exercise-card'); + const exerciseIndex = exerciseCard ? parseInt(exerciseCard.dataset.index) : null; + const workout = this.workouts.find(w => w.id === this.currentWorkoutId); + if (workout && exerciseIndex !== null) { + const value = e.target.value; + const exercise = workout.exercises[exerciseIndex]; + exercise[field] = e.target.type === 'number' ? (value ? parseInt(value) : null) : value; + if (field === 'reps' || field === 'time') { + const delayGroup = exerciseCard.querySelector('.delay-group'); + const showDelay = exercise.reps && exercise.time; + delayGroup.style.display = showDelay ? 'block' : 'none'; + } + } + }); + }, + + goHome() { + this.currentView = 'home'; + this.currentWorkoutId = null; + this.render(); + }, + async newWorkout() { + const w = { id: Date.now(), name: 'New Workout', exercises: [] }; + this.workouts.push(w); + await this.saveWorkouts(); + this.currentWorkoutId = w.id; + this.currentView = 'editor'; + this.render(); + }, + editWorkout(id) { + this.currentWorkoutId = id; + this.currentView = 'editor'; + this.render(); + }, + async deleteWorkout(id) { + if (confirm('Permanently delete this workout?')) { + this.workouts = this.workouts.filter(w => w.id !== id); + await this.saveWorkouts(); + this.goHome(); + } + }, + async cloneWorkout(id) { + const o = this.workouts.find(w => w.id === id); + if (o) { + const c = JSON.parse(JSON.stringify(o)); + c.id = Date.now(); + c.name = `${o.name} (Copy)`; + this.workouts.push(c); + await this.saveWorkouts(); + this.editWorkout(c.id); + } + }, + addExercise() { + const w = this.workouts.find(w => w.id === this.currentWorkoutId); + if (w) { w.exercises.push({ name: 'New Exercise', reps: null, time: null, delay: null }); this.render(); } + }, + deleteExercise(i) { + const w = this.workouts.find(w => w.id === this.currentWorkoutId); + if (w) { w.exercises.splice(i, 1); this.render(); } + }, + moveExercise(i, dir) { + const w = this.workouts.find(w => w.id === this.currentWorkoutId); + if (!w) return; + const newI = i + dir; + if (newI < 0 || newI >= w.exercises.length) return; + const [item] = w.exercises.splice(i, 1); + w.exercises.splice(newI, 0, item); + this.render(); + }, + async saveWorkoutFromEditor() { + const workout = this.workouts.find(w => w.id === this.currentWorkoutId); + if (workout) { + workout.name = document.getElementById('workout-name').value; + } + await this.saveWorkouts(); + this.goHome(); + }, + + startRunner(id) { + this.currentWorkoutId = id; this.currentView = 'runner'; this.currentExerciseIndex = 0; + this.acquireWakeLock(); + if (!this.runnerViewEl) { + this.runnerViewEl = document.createElement('div'); + this.runnerViewEl.className = 'runner-view'; + this.runnerViewEl.innerHTML = ` + +
+

+

+ +
+
+
`; + document.body.appendChild(this.runnerViewEl); + } + this.updateRunnerView(); + }, + stopRunner() { + clearInterval(this.runnerInterval); + this.releaseWakeLock(); + this.goHome(); + }, + initiateExercise() { + clearInterval(this.runnerInterval); + this.timerState = 'idle'; + this.updateRunnerView(); + }, + runGetReadyCountdown() { + const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; + this.runnerViewEl.querySelector('.runner-exercise-name').textContent = `Get Ready: ${exercise.name}`; + this.runnerViewEl.querySelector('.runner-description').textContent = exercise.description || ''; + this.runnerViewEl.querySelector('#runner-action-button').style.display = 'none'; + this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; + const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); + let countdown = 3; timerDisplay.textContent = countdown; this.playSound('tick'); + this.runnerInterval = setInterval(() => { + countdown--; + if (countdown > 0) { + timerDisplay.textContent = countdown; this.playSound('tick'); + } else { + clearInterval(this.runnerInterval); this.startCurrentExerciseTimer(true); + } + }, 1000); + }, + updateRunnerView() { + const workout = this.workouts.find(w => w.id === this.currentWorkoutId); + if (!workout) { this.stopRunner(); return; } + const exercise = workout.exercises[this.currentExerciseIndex]; + const isLastExercise = this.currentExerciseIndex === workout.exercises.length - 1; + + // Update progress pips + this.updateProgressPips(workout.exercises.length, this.currentExerciseIndex); + + this.runnerViewEl.querySelector('.runner-exercise-name').textContent = exercise.name; + this.runnerViewEl.querySelector('.runner-description').textContent = exercise.description || ''; + const actionButton = this.runnerViewEl.querySelector('#runner-action-button'); + const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display'); + const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); + timerDisplay.textContent = ''; + repsDisplay.textContent = ''; + this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; + actionButton.style.display = 'block'; + if (exercise.reps && !exercise.time) { + repsDisplay.textContent = `${exercise.reps} Reps`; + actionButton.textContent = 'Complete'; + actionButton.dataset.action = isLastExercise ? 'finish-workout' : 'next-exercise'; + } else if (exercise.time) { + repsDisplay.textContent = exercise.reps ? `Set: ${exercise.reps} x ${exercise.time}s` : `Set: ${exercise.time}s`; + actionButton.textContent = 'Start Exercise'; + actionButton.dataset.action = 'get-ready'; + } else { + repsDisplay.textContent = ''; + actionButton.textContent = 'Complete'; + actionButton.dataset.action = isLastExercise ? 'finish-workout' : 'next-exercise'; + } + const prevButton = this.runnerViewEl.querySelector('[data-action="prev-exercise"]'); + const nextButton = this.runnerViewEl.querySelector('[data-action="next-exercise"]'); + prevButton.disabled = this.currentExerciseIndex === 0; + nextButton.disabled = isLastExercise; + }, + + updateProgressPips(totalExercises, currentExercise) { + const progressContainer = this.runnerViewEl.querySelector('.runner-progress'); + progressContainer.innerHTML = ''; + + for (let i = 0; i < totalExercises; i++) { + const pip = document.createElement('div'); + pip.className = `runner-pip ${i === currentExercise ? 'active' : ''}`; + progressContainer.appendChild(pip); + } + }, + startCurrentExerciseTimer(isFirstRep = false) { + this.timerState = 'running'; + const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; + this.runnerViewEl.querySelector('.runner-exercise-name').textContent = exercise.name; + if (isFirstRep) this.playSound('start'); + this.runnerViewEl.querySelector('#runner-action-button').style.display = 'none'; + this.runnerViewEl.querySelector('#timer-controls').style.display = 'flex'; + const pauseBtn = this.runnerViewEl.querySelector('#pause-resume-button'); + pauseBtn.textContent = 'Pause'; pauseBtn.dataset.action = 'pause-timer'; + if (exercise.reps) { + this.runRepTimer(exercise, 1); + } else { + this.runSingleTimer(exercise.time); + } + }, + changeExercise(direction) { + const workout = this.workouts.find(w => w.id === this.currentWorkoutId); + if (!workout) { this.stopRunner(); return; } + const newIndex = this.currentExerciseIndex + direction; + if (newIndex >= 0 && newIndex < workout.exercises.length) { + this.currentExerciseIndex = newIndex; + this.initiateExercise(); + } + }, + completeExercise() { + this.playSound('exercise-complete'); + this.timerState = 'idle'; + clearInterval(this.runnerInterval); + const workout = this.workouts.find(w => w.id === this.currentWorkoutId); + const isLastExercise = this.currentExerciseIndex === workout.exercises.length - 1; + if (isLastExercise) { + this.renderCompletionScreen(); + } else { + this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; + const actionButton = this.runnerViewEl.querySelector('#runner-action-button'); + actionButton.style.display = 'block'; + actionButton.textContent = 'Next Exercise'; + actionButton.dataset.action = 'next-exercise'; + } + }, + renderCompletionScreen() { + this.playSound('workout-complete'); + const contentEl = this.runnerViewEl.querySelector('.runner-content'); + this.runnerViewEl.querySelector('.runner-nav').style.display = 'none'; + contentEl.innerHTML = ` +

Workout Complete!

+

Great job!

+ + `; + }, + runSingleTimer(duration) { + let timeLeft = duration; + const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); + timerDisplay.textContent = timeLeft; + this.runnerInterval = setInterval(() => { + timeLeft--; + timerDisplay.textContent = timeLeft; + if (timeLeft > 0 && timeLeft <= 5) this.playSound('countdown'); + if (timeLeft <= 0) { + this.completeExercise(); + } + }, 1000); + }, + runRepTimer(exercise, currentRep, startTime) { + this.timerState = 'running'; + const { reps, time, delay } = exercise; + if (currentRep > 1 && !startTime) this.playSound('rep-start'); + const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display'); + repsDisplay.textContent = `Rep ${currentRep} / ${reps}`; + const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); + let timeLeft = startTime || time; + timerDisplay.textContent = timeLeft; + this.runnerInterval = setInterval(() => { + timeLeft--; + timerDisplay.textContent = timeLeft; + if (timeLeft > 0 && timeLeft <= 5) this.playSound('countdown'); + if (timeLeft <= 0) { + clearInterval(this.runnerInterval); + if (currentRep < reps) { + this.playSound('rep-complete'); + setTimeout(() => this.runDelayTimer(exercise, currentRep), 250); + } else { + this.completeExercise(); + } + } + }, 1000); + }, + runDelayTimer(exercise, currentRep) { + this.runnerViewEl.querySelector('#runner-reps-display').textContent = `Rest`; + const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); + let delayLeft = exercise.delay; + timerDisplay.textContent = delayLeft; + this.runnerInterval = setInterval(() => { + delayLeft--; + timerDisplay.textContent = delayLeft; + if (delayLeft > 0) this.playSound('tick'); + if (delayLeft <= 0) { + clearInterval(this.runnerInterval); + this.runRepTimer(exercise, currentRep + 1); + } + }, 1000); + }, + pauseTimer() { + if (this.timerState !== 'running') return; + clearInterval(this.runnerInterval); + this.timerState = 'paused'; + this.timeRemainingOnPause = parseInt(this.runnerViewEl.querySelector('#runner-timer-display').textContent); + const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display').textContent; + if (repsDisplay.includes('Rep')) { + this.currentRepOnPause = parseInt(repsDisplay.split(' ')[1]); + } + const pauseResumeBtn = this.runnerViewEl.querySelector('#pause-resume-button'); + pauseResumeBtn.textContent = 'Resume'; + pauseResumeBtn.dataset.action = 'resume-timer'; + }, + resumeTimer() { + if (this.timerState !== 'paused') return; + this.timerState = 'running'; + const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; + const pauseResumeBtn = this.runnerViewEl.querySelector('#pause-resume-button'); + pauseResumeBtn.textContent = 'Pause'; + pauseResumeBtn.dataset.action = 'pause-timer'; + if (exercise.reps) { + this.runRepTimer(exercise, this.currentRepOnPause, this.timeRemainingOnPause); + } else { + this.runSingleTimer(this.timeRemainingOnPause); + } + }, + resetTimer() { + clearInterval(this.runnerInterval); + this.timerState = 'idle'; + this.updateRunnerView(); + } + }; + + await app.init(); +}); diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..88d1d9f Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..ecaf310 Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/public/icons/icon.afphoto b/public/icons/icon.afphoto new file mode 100644 index 0000000..51af66d Binary files /dev/null and b/public/icons/icon.afphoto differ diff --git a/public/idb.umd.js b/public/idb.umd.js new file mode 100644 index 0000000..03745a8 --- /dev/null +++ b/public/idb.umd.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).idb={})}(this,(function(e){"use strict";const t=(e,t)=>t.some((t=>e instanceof t));let n,r;const o=new WeakMap,s=new WeakMap,i=new WeakMap;let a={get(e,t,n){if(e instanceof IDBTransaction){if("done"===t)return o.get(e);if("store"===t)return n.objectStoreNames[1]?void 0:n.objectStore(n.objectStoreNames[0])}return f(e[t])},set:(e,t,n)=>(e[t]=n,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function c(e){a=e(a)}function u(e){return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(l(this),t),f(this.request)}:function(...t){return f(e.apply(l(this),t))}}function d(e){return"function"==typeof e?u(e):(e instanceof IDBTransaction&&function(e){if(o.has(e))return;const t=new Promise(((t,n)=>{const r=()=>{e.removeEventListener("complete",o),e.removeEventListener("error",s),e.removeEventListener("abort",s)},o=()=>{t(),r()},s=()=>{n(e.error||new DOMException("AbortError","AbortError")),r()};e.addEventListener("complete",o),e.addEventListener("error",s),e.addEventListener("abort",s)}));o.set(e,t)}(e),t(e,n||(n=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction]))?new Proxy(e,a):e)}function f(e){if(e instanceof IDBRequest)return function(e){const t=new Promise(((t,n)=>{const r=()=>{e.removeEventListener("success",o),e.removeEventListener("error",s)},o=()=>{t(f(e.result)),r()},s=()=>{n(e.error),r()};e.addEventListener("success",o),e.addEventListener("error",s)}));return i.set(t,e),t}(e);if(s.has(e))return s.get(e);const t=d(e);return t!==e&&(s.set(e,t),i.set(t,e)),t}const l=e=>i.get(e);const p=["get","getKey","getAll","getAllKeys","count"],D=["put","add","delete","clear"],I=new Map;function y(e,t){if(!(e instanceof IDBDatabase)||t in e||"string"!=typeof t)return;if(I.get(t))return I.get(t);const n=t.replace(/FromIndex$/,""),r=t!==n,o=D.includes(n);if(!(n in(r?IDBIndex:IDBObjectStore).prototype)||!o&&!p.includes(n))return;const s=async function(e,...t){const s=this.transaction(e,o?"readwrite":"readonly");let i=s.store;return r&&(i=i.index(t.shift())),(await Promise.all([i[n](...t),o&&s.done]))[0]};return I.set(t,s),s}c((e=>({...e,get:(t,n,r)=>y(t,n)||e.get(t,n,r),has:(t,n)=>!!y(t,n)||e.has(t,n)})));const B=["continue","continuePrimaryKey","advance"],b={},g=new WeakMap,v=new WeakMap,h={get(e,t){if(!B.includes(t))return e[t];let n=b[t];return n||(n=b[t]=function(...e){g.set(this,v.get(this)[t](...e))}),n}};async function*m(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;const n=new Proxy(t,h);for(v.set(n,t),i.set(n,l(t));t;)yield n,t=await(g.get(n)||t.continue()),g.delete(n)}function w(e,n){return n===Symbol.asyncIterator&&t(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===n&&t(e,[IDBIndex,IDBObjectStore])}c((e=>({...e,get:(t,n,r)=>w(t,n)?m:e.get(t,n,r),has:(t,n)=>w(t,n)||e.has(t,n)}))),e.deleteDB=function(e,{blocked:t}={}){const n=indexedDB.deleteDatabase(e);return t&&n.addEventListener("blocked",(e=>t(e.oldVersion,e))),f(n).then((()=>{}))},e.openDB=function(e,t,{blocked:n,upgrade:r,blocking:o,terminated:s}={}){const i=indexedDB.open(e,t),a=f(i);return r&&i.addEventListener("upgradeneeded",(e=>{r(f(i.result),e.oldVersion,e.newVersion,f(i.transaction),e)})),n&&i.addEventListener("blocked",(e=>n(e.oldVersion,e.newVersion,e))),a.then((e=>{s&&e.addEventListener("close",(()=>s())),o&&e.addEventListener("versionchange",(e=>o(e.oldVersion,e.newVersion,e)))})).catch((()=>{})),a},e.unwrap=l,e.wrap=f})); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..219053e --- /dev/null +++ b/public/index.html @@ -0,0 +1,26 @@ + + + + + + WorkIt + + + + + + + + + + + + + + +
+ + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..716ca77 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "WorkIt", + "short_name": "WorkIt", + "start_url": "/", + "display": "standalone", + "background_color": "#121212", + "theme_color": "#007bff", + "description": "A simple workout planner and runner.", + "icons": [ + { + "src": "icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..9552c6a --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,60 @@ +const CACHE_NAME = 'workout-runner-cache-v1.2'; + +const urlsToCache = [ + '/', + '/index.html', + '/style.css', + '/app.js', + '/manifest.json', + '/idb.umd.js', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', +]; + +self.addEventListener('install', event => { + self.skipWaiting(); + + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('Opened cache and caching app shell'); + return cache.addAll(urlsToCache); + }) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME) { + console.log('Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + return self.clients.claim(); + }) + ); +}); + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') { + return; + } + + event.respondWith( + fetch(event.request) + .then(networkResponse => { + return caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, networkResponse.clone()); + return networkResponse; + }); + }) + .catch(() => { + return caches.match(event.request); + }) + ); +}); diff --git a/public/sounds/countdown.mp3 b/public/sounds/countdown.mp3 new file mode 100644 index 0000000..d0c6dbc Binary files /dev/null and b/public/sounds/countdown.mp3 differ diff --git a/public/sounds/exercise-complete.mp3 b/public/sounds/exercise-complete.mp3 new file mode 100644 index 0000000..65df658 Binary files /dev/null and b/public/sounds/exercise-complete.mp3 differ diff --git a/public/sounds/rep-complete.mp3 b/public/sounds/rep-complete.mp3 new file mode 100644 index 0000000..53b60f1 Binary files /dev/null and b/public/sounds/rep-complete.mp3 differ diff --git a/public/sounds/rep-start.mp3 b/public/sounds/rep-start.mp3 new file mode 100644 index 0000000..8c2c1bb Binary files /dev/null and b/public/sounds/rep-start.mp3 differ diff --git a/public/sounds/start.mp3 b/public/sounds/start.mp3 new file mode 100644 index 0000000..4bf82a9 Binary files /dev/null and b/public/sounds/start.mp3 differ diff --git a/public/sounds/tick.mp3 b/public/sounds/tick.mp3 new file mode 100644 index 0000000..f1f99d7 Binary files /dev/null and b/public/sounds/tick.mp3 differ diff --git a/public/sounds/workout-complete.mp3 b/public/sounds/workout-complete.mp3 new file mode 100644 index 0000000..8a1c837 Binary files /dev/null and b/public/sounds/workout-complete.mp3 differ diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..f9e513d --- /dev/null +++ b/public/style.css @@ -0,0 +1,107 @@ +:root { + --primary-color: #007bff; + --secondary-color: #6c757d; + --background-color: #121212; + --surface-color: #1e1e1e; + --text-color: #e0e0e0; + --danger-color: #dc3545; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + overscroll-behavior: contain; +} + +#app-container { padding: 1rem; max-width: 800px; margin: 0 auto; } + +/* --- General UI Elements --- */ +h1, h2 { color: var(--primary-color); margin-bottom: 1rem; text-align: center; } + +.button { + display: block; width: 100%; padding: 1rem; font-size: 1.2rem; font-weight: bold; + text-align: center; border-radius: 8px; border: none; cursor: pointer; + background-color: var(--primary-color); color: white; margin-bottom: 0.5rem; + -webkit-tap-highlight-color: transparent; +} +.button.secondary { background-color: var(--secondary-color); } +.button.danger { background-color: var(--danger-color); } + +.input-group { margin-bottom: 1rem; } +.input-group label { display: block; margin-bottom: 0.5rem; color: var(--text-color); } +.input-group input, .input-group textarea { + width: 100%; padding: 0.8rem; background-color: var(--surface-color); + border: 1px solid var(--secondary-color); color: var(--text-color); + border-radius: 4px; font-size: 1rem; +} + +/* --- Home Screen --- */ +.workout-list-item { + background-color: var(--surface-color); padding: 1rem; border-radius: 8px; + margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; +} +.workout-list-item h3 { margin: 0; } + +/* REVISED: Home screen buttons are now just Edit and Start */ +.workout-actions button { + border: none; color: var(--text-color); font-weight: bold; cursor: pointer; + padding: 0.8rem; margin-left: 0.5rem; border-radius: 6px; + min-width: 60px; font-size: 1rem; +} +.workout-actions button[data-action="edit"] { background-color: var(--secondary-color); } +.workout-actions button[data-action="start"] { + background-color: var(--primary-color); + font-size: 1.5rem; /* Make play symbol larger */ + line-height: 1; + padding: 0.8rem 1.2rem; +} + +/* --- Workout Editor --- */ +.exercise-card { + background-color: var(--surface-color); padding: 1rem; border-radius: 8px; + margin-bottom: 1rem; border-left: 5px solid var(--primary-color); +} +.exercise-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.exercise-header h4 { font-size: 1.2rem; } +.exercise-controls button { + background: none; border: none; font-size: 1.8rem; color: var(--text-color); cursor: pointer; + padding: 0.8rem; margin-left: 0.5rem; min-width: 44px; min-height: 44px; + display: inline-flex; align-items: center; justify-content: center; +} +.exercise-controls button.delete { color: var(--danger-color); } +.exercise-details p { margin: 0.5rem 0; color: #aaa; font-family: inherit; } + +/* NEW: Container for action buttons in the editor */ +.editor-actions { + margin-top: 2rem; + border-top: 1px solid var(--surface-color); + padding-top: 1.5rem; +} +.editor-actions .button { margin-top: 0.5rem; } + +/* --- Workout Runner (No changes) --- */ +.runner-view { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: var(--background-color); display: flex; flex-direction: column; justify-content: space-between; align-items: center; text-align: center; padding: 1rem; z-index: 1000; } +.runner-content { position: relative; z-index: 1; width: 100%; max-width: 600px; flex: 1; display: flex; flex-direction: column; justify-content: center; } +.runner-exercise-name { font-size: 2.5rem; font-weight: bold; margin-bottom: 1rem; } +.runner-timer { font-size: 8rem; font-weight: bold; line-height: 1; margin: 2rem 0; font-family: 'monospace'; } +.runner-reps { font-size: 2rem; color: var(--primary-color); margin-bottom: 1rem; } +.runner-description { font-size: 1.2rem; color: #aaa; max-width: 90%; margin: 0 auto 1.5rem; } +.runner-view .button { max-width: 300px; margin: 1rem auto 0; } +.runner-progress { width: 100%; max-width: 400px; display: flex; justify-content: center; gap: 0.5rem; margin: 1rem 0; } +.runner-pip { width: 8px; height: 8px; border-radius: 50%; background-color: var(--secondary-color); } +.runner-pip.active { background-color: var(--primary-color); width: 24px; border-radius: 4px; } +.runner-nav { width: 100%; display: flex; justify-content: space-between; padding: 1rem 0; border-top: 1px solid var(--surface-color); } +.nav-button { width: 60px; height: 60px; display: flex; justify-content: center; align-items: center; padding-bottom: 7px; font-size: 2rem; color: var(--primary-color); background-color: var(--surface-color); border: 2px solid var(--primary-color); border-radius: 50%; cursor: pointer; -webkit-tap-highlight-color: transparent; user-select: none; transition: all 0.2s ease; } +.nav-button:hover, .nav-button:active { background-color: var(--primary-color); color: var(--background-color); transform: scale(1.1); } +.nav-button:disabled { opacity: 0.3; cursor: not-allowed; transform: none; } +.runner-close-button { + position: absolute; top: 1rem; right: 1rem; + background: var(--danger-color); border: none; + color: white; font-size: 1rem; font-weight: bold; + padding: 0.8rem 1.5rem; border-radius: 6px; cursor: pointer; z-index: 20; +} +.timer-controls { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; } +.timer-controls .button { width: auto; padding: 1rem 2rem; flex-grow: 1; max-width: 200px; } diff --git a/scripts/inline-audio.js b/scripts/inline-audio.js new file mode 100644 index 0000000..bdcba0d --- /dev/null +++ b/scripts/inline-audio.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); + +const soundsDir = path.join(__dirname, '..', 'public_build', 'sounds'); +const appJsPath = path.join(__dirname, '..', 'public_build', 'app.js'); +const placeholder = '// __SOUND_DATA_PLACEHOLDER__'; + +console.log('--- Inlining audio files into app.js ---'); + +try { + const soundFiles = fs.readdirSync(soundsDir).filter(file => file.endsWith('.mp3')); + const soundData = {}; + + soundFiles.forEach(file => { + const filePath = path.join(soundsDir, file); + const fileBuffer = fs.readFileSync(filePath); + const base64String = fileBuffer.toString('base64'); + const key = path.basename(file, '.mp3'); + soundData[key] = `data:audio/mpeg;base64,${base64String}`; + console.log(` - Inlined ${file}`); + }); + + const soundDataJSON = JSON.stringify(soundData, null, 2); + const replacementString = `const soundData = ${soundDataJSON};`; + + let appJsContent = fs.readFileSync(appJsPath, 'utf8'); + if (appJsContent.includes(placeholder)) { + appJsContent = appJsContent.replace(placeholder, replacementString); + fs.writeFileSync(appJsPath, appJsContent, 'utf8'); + console.log('--- Successfully injected sound data into app.js ---'); + } else { + throw new Error(`Placeholder "${placeholder}" not found in app.js!`); + } + +} catch (error) { + console.error('!!! Error inlining audio:', error.message); + process.exit(1); +}