Initial commit

This commit is contained in:
Devon Campbell 2025-10-19 15:33:51 -04:00
commit 960e9e0ff8
20 changed files with 877 additions and 0 deletions

11
Dockerfile Normal file
View file

@ -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

60
build.sh Executable file
View file

@ -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}"

11
docker-compose.yaml Normal file
View file

@ -0,0 +1,11 @@
services:
workout-app:
build: .
container_name: workout-app
ports:
- "8080:80" # Access the app at http://<your-server-ip>: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

542
public/app.js Normal file
View file

@ -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 = '&#9654;'; }
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 = `<h1>My Workouts</h1>`;
if (Array.isArray(this.workouts)) {
this.workouts.forEach(workout => {
content += `
<div class="workout-list-item" data-id="${workout.id}">
<h3>${workout.name}</h3>
<div class="workout-actions">
<button data-action="edit" title="Edit">Edit</button>
<button data-action="start" title="Start Workout">&#9654;</button>
</div>
</div>
`;
});
}
content += `<button class="button" data-action="new-workout">Create New Workout</button>`;
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 += `
<div class="exercise-card" data-index="${index}">
<div class="exercise-header">
<h4>${ex.name || 'New Exercise'}</h4>
<div class="exercise-controls">
<button data-action="move-up" title="Move Up">&#8593;</button>
<button data-action="move-down" title="Move Down">&#8595;</button>
<button data-action="delete-exercise" class="delete" title="Delete Exercise">&times;</button>
</div>
</div>
<div class="exercise-details"> <p>${ex.description || ''}</p> <p><strong>Reps:</strong> ${ex.reps || 'N/A'} | <strong>Time:</strong> ${ex.time || 'N/A'}s | <strong>Delay:</strong> ${ex.delay || 'N/A'}s</p> </div>
<div class="input-group"> <label>Name</label> <input type="text" data-field="name" value="${ex.name || ''}"> </div>
<div class="input-group"> <label>Description</label> <textarea data-field="description">${ex.description || ''}</textarea> </div>
<div class="input-group"> <label>Reps</label> <input type="number" data-field="reps" value="${ex.reps || ''}"> </div>
<div class="input-group"> <label>Time (seconds)</label> <input type="number" data-field="time" value="${ex.time || ''}"> </div>
<div class="input-group delay-group" style="display: ${showDelay ? 'block' : 'none'};"> <label>Delay between reps (seconds)</label> <input type="number" data-field="delay" value="${ex.delay || ''}"> </div>
</div>
`;
});
const content = `
<div class="input-group"> <label for="workout-name">Workout Name</label> <input type="text" id="workout-name" value="${workout.name}"> </div>
<h2>Exercises</h2> <div id="exercise-list">${exercisesHtml}</div>
<button class="button secondary" data-action="add-exercise">Add Exercise</button>
<button class="button" data-action="save-workout">Save Workout</button>
<div class="editor-actions">
<button class="button secondary" data-action="clone-workout">Clone Workout</button>
<button class="button danger" data-action="delete-workout">Delete Workout</button>
</div>
<button class="button secondary" data-action="back-home" style="margin-top: 2rem;">Back to Home</button>
`;
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 = `
<button class="runner-close-button" data-action="stop-runner">End Workout</button>
<div class="runner-content">
<h2 class="runner-exercise-name"></h2><p class="runner-reps" id="runner-reps-display"></p><div class="runner-timer" id="runner-timer-display"></div>
<p class="runner-description"></p><button class="button" id="runner-action-button"></button>
<div class="timer-controls" id="timer-controls" style="display: none;">
<button class="button secondary" id="pause-resume-button"></button><button class="button danger" data-action="reset-timer">Reset</button>
</div>
</div>
<div class="runner-progress"></div>
<div class="runner-nav"><div class="nav-button" data-action="prev-exercise">&#8249;</div><div class="nav-button" data-action="next-exercise">&#8250;</div></div>`;
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 = `
<h2 class="runner-exercise-name">Workout Complete!</h2>
<p class="runner-description">Great job!</p>
<button class="button" data-action="stop-runner" style="margin-top: 3rem;">Back to Home</button>
`;
},
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();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
public/icons/icon.afphoto Normal file

Binary file not shown.

1
public/idb.umd.js Normal file
View file

@ -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}));

26
public/index.html Normal file
View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>WorkIt</title>
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#121212">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="WorkIt">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="app-container"></main>
<script src="/idb.umd.js"></script>
<script src="app.js"></script>
</body>
</html>

21
public/manifest.json Normal file
View file

@ -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"
}
]
}

60
public/service-worker.js Normal file
View file

@ -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);
})
);
});

BIN
public/sounds/countdown.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/sounds/rep-start.mp3 Normal file

Binary file not shown.

BIN
public/sounds/start.mp3 Normal file

Binary file not shown.

BIN
public/sounds/tick.mp3 Normal file

Binary file not shown.

Binary file not shown.

107
public/style.css Normal file
View file

@ -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; }

38
scripts/inline-audio.js Normal file
View file

@ -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);
}