Initial commit
This commit is contained in:
commit
960e9e0ff8
20 changed files with 877 additions and 0 deletions
11
Dockerfile
Normal file
11
Dockerfile
Normal 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
60
build.sh
Executable 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
11
docker-compose.yaml
Normal 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
542
public/app.js
Normal 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 = '▶'; }
|
||||
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">▶</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">↑</button>
|
||||
<button data-action="move-down" title="Move Down">↓</button>
|
||||
<button data-action="delete-exercise" class="delete" title="Delete Exercise">×</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">‹</div><div class="nav-button" data-action="next-exercise">›</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();
|
||||
});
|
||||
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
BIN
public/icons/icon.afphoto
Normal file
BIN
public/icons/icon.afphoto
Normal file
Binary file not shown.
1
public/idb.umd.js
Normal file
1
public/idb.umd.js
Normal 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
26
public/index.html
Normal 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
21
public/manifest.json
Normal 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
60
public/service-worker.js
Normal 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
BIN
public/sounds/countdown.mp3
Normal file
Binary file not shown.
BIN
public/sounds/exercise-complete.mp3
Normal file
BIN
public/sounds/exercise-complete.mp3
Normal file
Binary file not shown.
BIN
public/sounds/rep-complete.mp3
Normal file
BIN
public/sounds/rep-complete.mp3
Normal file
Binary file not shown.
BIN
public/sounds/rep-start.mp3
Normal file
BIN
public/sounds/rep-start.mp3
Normal file
Binary file not shown.
BIN
public/sounds/start.mp3
Normal file
BIN
public/sounds/start.mp3
Normal file
Binary file not shown.
BIN
public/sounds/tick.mp3
Normal file
BIN
public/sounds/tick.mp3
Normal file
Binary file not shown.
BIN
public/sounds/workout-complete.mp3
Normal file
BIN
public/sounds/workout-complete.mp3
Normal file
Binary file not shown.
107
public/style.css
Normal file
107
public/style.css
Normal 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
38
scripts/inline-audio.js
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue