Initial commit - Church Music Database

This commit is contained in:
2026-01-27 18:04:50 -06:00
commit d367261867
336 changed files with 103545 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# Frontend Environment Configuration for Ubuntu
# Copy this to .env.production before building
# API URL - Update with your server domain or IP
REACT_APP_API_URL=http://your-domain.com/api
# For HTTPS (recommended):
# REACT_APP_API_URL=https://your-domain.com/api
# For IP-based access:
# REACT_APP_API_URL=http://your-server-ip/api
# Build configuration
GENERATE_SOURCEMAP=false
# WebSocket configuration for HTTPS (only needed for development server)
# In production builds, these are not included
# WDS_SOCKET_PROTOCOL=wss
# WDS_SOCKET_HOST=your-domain.com
# WDS_SOCKET_PORT=5100

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "src",
"forceConsistentCasingInFileNames": false
},
"include": [
"src"
]
}

17277
legacy-site/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "house-of-prayer-songlyric-frontend",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:5100",
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"crypto-js": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-scripts": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.22",
"http-proxy-middleware": "^3.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Pre-start check for frontend service
# Kills any development servers before starting production frontend
# Source the main kill script
/media/pts/Website/Church_HOP_MusicData/kill-dev-servers.sh
exit 0

View File

@@ -0,0 +1,40 @@
// Service Worker Unregister - Forces cache clear
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.unregister();
console.log("Service Worker unregistered");
}
});
}
// Clear all caches
if ("caches" in window) {
caches.keys().then(function (cacheNames) {
cacheNames.forEach(function (cacheName) {
caches.delete(cacheName);
console.log("Cache deleted:", cacheName);
});
});
}
// Force reload with cache bypass
const APP_VERSION = "v2025.12.15.2319";
const storedVersion = localStorage.getItem("app_version");
if (storedVersion !== APP_VERSION) {
console.log("New version detected, clearing cache...");
localStorage.setItem("app_version", APP_VERSION);
// Clear localStorage except version
const keysToKeep = ["app_version", "selected_profile_id"];
const allKeys = Object.keys(localStorage);
allKeys.forEach((key) => {
if (!keysToKeep.includes(key)) {
localStorage.removeItem(key);
}
});
// Hard reload
window.location.reload(true);
}

View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings Diagnostic</title>
<style>
body {
font-family: Arial;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
}
.status {
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.good {
background: #d4edda;
color: #155724;
}
.bad {
background: #f8d7da;
color: #721c24;
}
.warning {
background: #fff3cd;
color: #856404;
}
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h1>🔍 Settings Diagnostic Tool</h1>
<div id="results"></div>
<h3>Quick Actions:</h3>
<button onclick="fixSettings()">
🔧 Fix Settings (Set to Backend Mode)
</button>
<button onclick="testConnection()">🌐 Test Backend Connection</button>
<button onclick="restoreData()">📦 Restore Data to Backend</button>
<button onclick="clearAll()">🗑️ Clear All Settings</button>
</div>
<script>
function check() {
const results = document.getElementById("results");
let html = "";
// Check API settings
const apiSettings = localStorage.getItem("api_settings");
if (apiSettings) {
try {
const settings = JSON.parse(apiSettings);
html +=
'<div class="status ' +
(settings.useLocalStorage ? "warning" : "good") +
'">';
html += "<strong>API Settings Found:</strong><br>";
html +=
"Local Mode: <strong>" +
(settings.useLocalStorage ? "ON (❌ Issue)" : "OFF (✓)") +
"</strong><br>";
html += "Protocol: " + settings.protocol + "<br>";
html += "Hostname: " + settings.hostname + "<br>";
html += "Port: " + settings.port + "<br>";
html += "</div>";
if (settings.useLocalStorage) {
html += '<div class="status bad">';
html +=
"⚠️ <strong>Local Mode is ON</strong> — songs save only in this browser, not to the backend. Turn it OFF with Fix Settings.";
html += "</div>";
}
} catch (e) {
html +=
'<div class="status bad">Error parsing settings: ' +
e.message +
"</div>";
}
} else {
html +=
'<div class="status bad">No API settings found. Defaults may enable Local Mode (OFFLINE). Click Fix Settings.</div>';
}
// Check localStorage data
const songs = localStorage.getItem("SONGS");
const profiles = localStorage.getItem("PROFILES");
const plans = localStorage.getItem("PLANS");
html += '<div class="status">';
html += "<strong>Local Storage (this device only):</strong><br>";
html +=
"Songs: " +
(songs ? JSON.parse(songs).length + " song(s)" : "0 song(s)") +
"<br>";
html +=
"Profiles: " +
(profiles
? JSON.parse(profiles).length + " profile(s)"
: "0 profile(s)") +
"<br>";
html +=
"Plans: " +
(plans ? JSON.parse(plans).length + " plan(s)" : "0 plan(s)") +
"<br>";
html += "</div>";
results.innerHTML = html;
}
function fixSettings() {
const isDesktop =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
const hostname = isDesktop ? "localhost" : window.location.hostname;
const settings = {
protocol: "http",
hostname: hostname,
port: "5000",
useLocalStorage: false,
};
localStorage.setItem("api_settings", JSON.stringify(settings));
alert(
"✓ Settings fixed! Local Mode is now OFF.\nHostname: " +
hostname +
"\nPort: 5000\n\nGo back to main app and refresh."
);
check();
}
async function testConnection() {
const apiSettings = localStorage.getItem("api_settings");
if (!apiSettings) {
alert('No settings found. Click "Fix Settings" first.');
return;
}
const settings = JSON.parse(apiSettings);
const url =
settings.protocol +
"://" +
settings.hostname +
":" +
settings.port +
"/api/health";
try {
const response = await fetch(url, { method: "GET" });
const data = await response.json();
alert(
"✓ Backend connection successful!\nStatus: " +
data.status +
"\n\nYou can now save songs to backend."
);
} catch (e) {
alert(
"✗ Backend connection failed!\n\n" +
e.message +
"\n\nCheck:\n1. Backend is running\n2. Hostname/IP is correct\n3. Port 5000 is accessible"
);
}
}
async function restoreData() {
const apiSettings = localStorage.getItem("api_settings");
if (!apiSettings) {
alert('No settings found. Click "Fix Settings" first.');
return;
}
const settings = JSON.parse(apiSettings);
const base = `${settings.protocol}://${settings.hostname}:${settings.port}`;
const url = `${base}/api/admin/restore`;
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
alert(
`✓ Restore complete!\nSource: ${
data.source || "data.json"
}\nProfiles: created ${data.profiles_created}, updated ${
data.profiles_updated
}\nSongs: created ${data.songs_created}, updated ${
data.songs_updated
}\n\nOpen the main app and run Full Sync.`
);
} catch (e) {
alert(
"✗ Restore failed.\n\n" +
e.message +
"\n\nEnsure backend is running and data.json exists (root or backend folder)."
);
}
}
function clearAll() {
if (
confirm("Clear ALL settings and local data? This cannot be undone!")
) {
localStorage.clear();
alert("✓ All cleared! Reload the page.");
check();
}
}
// Run check on load
check();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clear App Cache - Church Music Database</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #7c3aed;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
button {
background: #7c3aed;
color: white;
border: none;
padding: 15px 30px;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
margin: 10px 5px;
}
button:hover {
background: #6d28d9;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
.info {
background: #eff6ff;
border-left: 4px solid #3b82f6;
padding: 15px;
margin: 20px 0;
}
.success {
background: #f0fdf4;
border-left: 4px solid #22c55e;
padding: 15px;
margin: 20px 0;
}
.warning {
background: #fefce8;
border-left: 4px solid #eab308;
padding: 15px;
margin: 20px 0;
}
#output {
margin-top: 20px;
padding: 15px;
background: #f9fafb;
border-radius: 5px;
white-space: pre-wrap;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔧 Church Music Database - Cache Manager</h1>
<p class="subtitle">Clear cached data and fix persistent issues</p>
<div class="info">
<strong> What does this do?</strong><br />
This tool helps fix issues where deleted worship lists keep reappearing
by clearing your browser's local cache.
</div>
<div class="warning">
<strong>⚠️ Before you start:</strong><br />
Make sure any unsaved changes are backed up. This will clear all locally
cached data.
</div>
<button onclick="viewCache()">📊 View Current Cache</button>
<button onclick="clearPlansCache()">🗑️ Clear Worship Lists Cache</button>
<button onclick="clearAllCache()" class="danger">
🧹 Clear All App Data
</button>
<button onclick="location.href='/'">← Back to App</button>
<div id="output"></div>
</div>
<script>
const STORAGE_KEYS = {
SONGS: "hop_songs",
PROFILES: "hop_profiles",
PLANS: "hop_plans",
PLAN_SONGS: "hop_plan_songs",
SETTINGS: "hop_settings",
};
function log(message, type = "info") {
const output = document.getElementById("output");
const timestamp = new Date().toLocaleTimeString();
output.innerHTML += `[${timestamp}] ${message}\n`;
output.scrollTop = output.scrollHeight;
}
function viewCache() {
const output = document.getElementById("output");
output.innerHTML = "";
log("=== CURRENT CACHE CONTENTS ===\n", "info");
Object.entries(STORAGE_KEYS).forEach(([name, key]) => {
const data = localStorage.getItem(key);
if (data) {
try {
const parsed = JSON.parse(data);
const count = Array.isArray(parsed) ? parsed.length : 1;
log(`${name}: ${count} items`);
if (name === "PLANS" && Array.isArray(parsed)) {
parsed.forEach((plan) => {
log(
` - ID: ${plan.id}, Date: ${plan.date}, Notes: ${
plan.notes?.substring(0, 30) || "None"
}`
);
});
}
} catch (e) {
log(`${name}: Invalid JSON data`);
}
} else {
log(`${name}: No data`);
}
});
log("\n=== END CACHE CONTENTS ===");
}
function clearPlansCache() {
if (
!confirm(
"Clear worship lists cache? This will remove all locally cached worship lists."
)
) {
return;
}
const output = document.getElementById("output");
output.innerHTML = "";
log("Clearing worship lists cache...", "info");
const plansData = localStorage.getItem(STORAGE_KEYS.PLANS);
const planSongsData = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
if (plansData) {
const plans = JSON.parse(plansData);
log(`Found ${plans.length} cached worship lists`);
}
localStorage.removeItem(STORAGE_KEYS.PLANS);
localStorage.removeItem(STORAGE_KEYS.PLAN_SONGS);
log("✅ Worship lists cache cleared!", "success");
log("Please refresh the app to reload from the server.");
}
function clearAllCache() {
if (
!confirm(
"Clear ALL app data? This will remove all cached songs, profiles, and worship lists. The app will reload fresh from the server."
)
) {
return;
}
if (!confirm("Are you REALLY sure? This cannot be undone!")) {
return;
}
const output = document.getElementById("output");
output.innerHTML = "";
log("Clearing all app data...", "info");
Object.entries(STORAGE_KEYS).forEach(([name, key]) => {
const data = localStorage.getItem(key);
if (data) {
try {
const parsed = JSON.parse(data);
const count = Array.isArray(parsed) ? parsed.length : 1;
log(`Clearing ${name}: ${count} items`);
} catch (e) {
log(`Clearing ${name}`);
}
localStorage.removeItem(key);
}
});
log("\n✅ All app data cleared!", "success");
log("Redirecting to app in 3 seconds...");
setTimeout(() => {
window.location.href = "/";
}, 3000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Clear Settings</title>
</head>
<body>
<h1>Clearing old settings...</h1>
<script>
// Clear ALL old settings
localStorage.clear();
// Set correct settings based on ACTUAL current hostname
const actualHostname = window.location.hostname;
const correctSettings = {
protocol: "http",
hostname: actualHostname, // Use actual hostname (localhost, IP, or DNS)
port: "8080",
useLocalStorage: false,
};
localStorage.setItem("api_settings", JSON.stringify(correctSettings));
alert(
"✅ Settings cleared!\n\nHostname: " +
actualHostname +
"\nPort: 8080\n\nRedirecting to port 5100..."
);
window.location.href = "http://" + actualHostname + ":5100";
</script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00d4ff;stop-opacity:1" />
<stop offset="25%" style="stop-color:#00a8ff;stop-opacity:1" />
<stop offset="50%" style="stop-color:#6e5ff0;stop-opacity:1" />
<stop offset="75%" style="stop-color:#a855f7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
</linearGradient>
<!-- Subtle overlay patterns -->
<radialGradient id="overlay1" cx="20%" cy="20%" r="40%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.1" />
<stop offset="100%" style="stop-color:#ffffff;stop-opacity:0" />
</radialGradient>
<radialGradient id="overlay2" cx="80%" cy="80%" r="40%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.08" />
<stop offset="100%" style="stop-color:#ffffff;stop-opacity:0" />
</radialGradient>
</defs>
<!-- Main gradient background -->
<rect width="100%" height="100%" fill="url(#grad1)"/>
<!-- Subtle overlay effects -->
<rect width="100%" height="100%" fill="url(#overlay1)"/>
<rect width="100%" height="100%" fill="url(#overlay2)"/>
<!-- Subtle geometric shapes for depth -->
<circle cx="15%" cy="18%" r="150" fill="rgba(255,255,255,0.03)"/>
<circle cx="85%" cy="82%" r="200" fill="rgba(255,255,255,0.03)"/>
<path d="M 0 700 Q 400 600 800 700 T 1600 700 L 1920 1080 L 0 1080 Z" fill="rgba(0,0,0,0.05)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- FORCE NO CACHE -->
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- Cache Buster - Force reload on version change -->
<script src="%PUBLIC_URL%/cache-buster.js?v=2380"></script>
<!-- CryptoJS for password hashing -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"
integrity="sha512-a+SUDuwNzXDvz4XrIcXHuCf089/iJAoN4lmrXJg18XnduKK6YlDHNRalv4yd1N40OKI80tFidF+rqTFKGPoWFQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<title>HOP Worship App - Song Lyrics</title>
<!-- Favicon -->
<link rel="icon" href="%PUBLIC_URL%/church-logo.png" type="image/png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/church-logo.png" />
<!-- ABSOLUTE NUCLEAR OPTION: Kill ResizeObserver errors BEFORE React loads -->
<script>
// Override console.error IMMEDIATELY
const _originalError = console.error;
console.error = function (...args) {
if (
args[0] &&
typeof args[0] === "string" &&
(args[0].includes("ResizeObserver") ||
args[0].includes("loop completed"))
) {
return; // Silently ignore
}
_originalError.apply(console, args);
};
// Suppress Google Analytics blocked by ad blocker errors
const _originalWarn = console.warn;
console.warn = function (...args) {
if (
args[0] &&
typeof args[0] === "string" &&
(args[0].includes("google-analytics") ||
args[0].includes("ERR_BLOCKED_BY_CLIENT"))
) {
return; // Silently ignore
}
_originalWarn.apply(console, args);
};
// Catch all error events
window.addEventListener(
"error",
function (e) {
// Suppress Google Analytics blocked errors
if (
e.message &&
(e.message.includes("google-analytics") ||
e.message.includes("ERR_BLOCKED_BY_CLIENT") ||
e.message.includes("measurement_id"))
) {
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
return false;
}
if (
e.message &&
(e.message.includes("ResizeObserver") || e.message.includes("loop"))
) {
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
return false;
}
},
true
);
// Catch unhandled promise rejections (including fetch failures from ad blockers)
window.addEventListener("unhandledrejection", function (e) {
// Suppress Google Analytics fetch rejections
if (
e.reason &&
(e.reason.message?.includes("google-analytics") ||
e.reason.message?.includes("ERR_BLOCKED_BY_CLIENT") ||
(e.reason instanceof TypeError &&
e.reason.message?.includes("Failed to fetch")))
) {
e.preventDefault();
return false;
}
if (
e.reason &&
e.reason.message &&
e.reason.message.includes("ResizeObserver")
) {
e.preventDefault();
return false;
}
});
// Override window.onerror
window.onerror = function (msg, url, lineNo, columnNo, error) {
// Suppress Google Analytics errors
if (
typeof msg === "string" &&
(msg.includes("google-analytics") ||
msg.includes("ERR_BLOCKED_BY_CLIENT") ||
msg.includes("measurement_id"))
) {
return true; // Prevent default error handling
}
if (
typeof msg === "string" &&
(msg.includes("ResizeObserver") || msg.includes("loop"))
) {
return true; // Prevent default error handling
}
return false;
};
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,528 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Login Debugger</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 24px;
}
.test-section {
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.test-section h2 {
font-size: 16px;
color: #667eea;
margin-bottom: 10px;
}
.test-result {
padding: 8px 12px;
margin: 5px 0;
border-radius: 4px;
font-size: 14px;
}
.pass {
background: #d4edda;
color: #155724;
border-left: 3px solid #28a745;
}
.fail {
background: #f8d7da;
color: #721c24;
border-left: 3px solid #dc3545;
}
.info {
background: #d1ecf1;
color: #0c5460;
border-left: 3px solid #17a2b8;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
width: 100%;
margin: 10px 0;
font-weight: 600;
}
button:active {
transform: scale(0.98);
}
.login-form {
margin-top: 20px;
}
input {
width: 100%;
padding: 12px;
margin: 8px 0;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
}
input:focus {
outline: none;
border-color: #667eea;
}
label {
display: block;
margin-top: 10px;
color: #555;
font-weight: 600;
}
.code-block {
background: #2d2d2d;
color: #f8f8f2;
padding: 10px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 12px;
overflow-x: auto;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔍 Mobile Login Debugger</h1>
<div class="test-section">
<h2>System Tests</h2>
<div id="systemTests"></div>
<button onclick="runSystemTests()">Run System Tests</button>
</div>
<div class="test-section">
<h2>Storage Tests</h2>
<div id="storageTests"></div>
<button onclick="runStorageTests()">Test Storage Capabilities</button>
</div>
<div class="test-section">
<h2>CryptoJS Library Test</h2>
<div id="cryptoTests"></div>
<button onclick="runCryptoTests()">Test Crypto Library</button>
</div>
<div class="test-section">
<h2>Login Simulation</h2>
<div class="login-form">
<label for="testUsername">Username:</label>
<input
type="text"
id="testUsername"
value="hop"
placeholder="Enter username"
/>
<label for="testPassword">Password:</label>
<input
type="password"
id="testPassword"
placeholder="Enter password"
/>
<button onclick="testLogin()">Test Login</button>
<div id="loginResults"></div>
</div>
</div>
<div class="test-section">
<h2>Current Credentials</h2>
<div id="credentialInfo"></div>
<button onclick="showCredentials()">Show Stored Credentials</button>
</div>
<div class="test-section">
<h2>Clear All Storage</h2>
<button
onclick="clearAllStorage()"
style="background: linear-gradient(135deg, #dc3545 0%, #c82333 100%)"
>
Clear localStorage & sessionStorage
</button>
</div>
</div>
<!-- Load CryptoJS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
// Default credentials from App.js
const MASTER_USERNAME = "hop";
const MASTER_PASSWORD_HASH =
"5cdf907c69ae7a7f0c2e18a67e9b70a4c4fc35f9582637354c1bc45edf092a79";
function addResult(containerId, message, type) {
const container = document.getElementById(containerId);
const div = document.createElement("div");
div.className = `test-result ${type}`;
div.innerHTML = message;
container.appendChild(div);
}
function clearResults(containerId) {
document.getElementById(containerId).innerHTML = "";
}
function runSystemTests() {
clearResults("systemTests");
// User Agent
addResult(
"systemTests",
`<strong>User Agent:</strong><br>${navigator.userAgent}`,
"info"
);
// Browser Info
const browserInfo = {
Platform: navigator.platform,
Language: navigator.language,
Online: navigator.onLine,
"Cookies Enabled": navigator.cookieEnabled,
"Touch Support":
"ontouchstart" in window || navigator.maxTouchPoints > 0,
};
Object.entries(browserInfo).forEach(([key, value]) => {
addResult(
"systemTests",
`<strong>${key}:</strong> ${value}`,
value ? "pass" : "fail"
);
});
// Screen Info
addResult(
"systemTests",
`<strong>Screen:</strong> ${window.screen.width}x${window.screen.height} (${window.devicePixelRatio}x DPR)`,
"info"
);
addResult(
"systemTests",
`<strong>Viewport:</strong> ${window.innerWidth}x${window.innerHeight}`,
"info"
);
}
function runStorageTests() {
clearResults("storageTests");
// Test localStorage
try {
localStorage.setItem("test", "value");
const retrieved = localStorage.getItem("test");
localStorage.removeItem("test");
if (retrieved === "value") {
addResult("storageTests", "✓ localStorage is working", "pass");
} else {
addResult("storageTests", "✗ localStorage read failed", "fail");
}
} catch (e) {
addResult(
"storageTests",
`✗ localStorage failed: ${e.message}`,
"fail"
);
}
// Test sessionStorage
try {
sessionStorage.setItem("test", "value");
const retrieved = sessionStorage.getItem("test");
sessionStorage.removeItem("test");
if (retrieved === "value") {
addResult("storageTests", "✓ sessionStorage is working", "pass");
} else {
addResult("storageTests", "✗ sessionStorage read failed", "fail");
}
} catch (e) {
addResult(
"storageTests",
`✗ sessionStorage failed: ${e.message}`,
"fail"
);
}
// Check if in private browsing mode (heuristic)
try {
if (window.localStorage && window.sessionStorage) {
addResult(
"storageTests",
"✓ Not in private browsing mode (likely)",
"pass"
);
}
} catch (e) {
addResult(
"storageTests",
"⚠ Might be in private browsing mode",
"fail"
);
}
}
function runCryptoTests() {
clearResults("cryptoTests");
if (typeof CryptoJS === "undefined") {
addResult("cryptoTests", "✗ CryptoJS is not loaded!", "fail");
addResult(
"cryptoTests",
"This is likely the problem - the library failed to load on mobile",
"fail"
);
return;
}
addResult("cryptoTests", "✓ CryptoJS library is loaded", "pass");
// Test SHA256 hashing
try {
const testPassword = "hop@2026ilovejesus";
const hash = CryptoJS.SHA256(testPassword).toString();
addResult(
"cryptoTests",
`<strong>Test Hash:</strong><br><div class="code-block">${hash}</div>`,
"info"
);
if (hash === MASTER_PASSWORD_HASH) {
addResult(
"cryptoTests",
"✓ Password hashing works correctly!",
"pass"
);
} else {
addResult(
"cryptoTests",
"✗ Hash mismatch - encryption issue",
"fail"
);
}
} catch (e) {
addResult("cryptoTests", `✗ Hashing failed: ${e.message}`, "fail");
}
}
function testLogin() {
clearResults("loginResults");
const username = document.getElementById("testUsername").value;
const password = document.getElementById("testPassword").value;
if (!username || !password) {
addResult(
"loginResults",
"⚠ Please enter both username and password",
"fail"
);
return;
}
try {
// Check if CryptoJS is available
if (typeof CryptoJS === "undefined") {
addResult(
"loginResults",
"✗ CryptoJS not loaded - this is the problem!",
"fail"
);
return;
}
// Get stored credentials
const storedUsername =
localStorage.getItem("masterUsername") || MASTER_USERNAME;
const storedPasswordHash =
localStorage.getItem("masterPasswordHash") || MASTER_PASSWORD_HASH;
addResult(
"loginResults",
`<strong>Stored Username:</strong> ${storedUsername}`,
"info"
);
addResult(
"loginResults",
`<strong>Input Username:</strong> ${username}`,
"info"
);
// Hash the input password
const inputHash = CryptoJS.SHA256(password).toString();
addResult(
"loginResults",
`<strong>Input Hash:</strong><br><div class="code-block">${inputHash}</div>`,
"info"
);
addResult(
"loginResults",
`<strong>Expected Hash:</strong><br><div class="code-block">${storedPasswordHash}</div>`,
"info"
);
// Compare
if (username === storedUsername && inputHash === storedPasswordHash) {
addResult("loginResults", "✓ LOGIN WOULD SUCCEED!", "pass");
// Try to set session storage
try {
const sessionId = `session_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
sessionStorage.setItem("authenticated", "true");
sessionStorage.setItem("authTime", Date.now().toString());
sessionStorage.setItem("sessionId", sessionId);
addResult(
"loginResults",
"✓ Session data stored successfully",
"pass"
);
addResult(
"loginResults",
`<strong>Session ID:</strong> ${sessionId}`,
"info"
);
} catch (e) {
addResult(
"loginResults",
`✗ Failed to set session: ${e.message}`,
"fail"
);
}
} else {
if (username !== storedUsername) {
addResult("loginResults", "✗ Username does not match", "fail");
}
if (inputHash !== storedPasswordHash) {
addResult(
"loginResults",
"✗ Password hash does not match",
"fail"
);
}
}
} catch (e) {
addResult(
"loginResults",
`✗ Login test failed: ${e.message}`,
"fail"
);
}
}
function showCredentials() {
clearResults("credentialInfo");
const storedUsername = localStorage.getItem("masterUsername");
const storedPasswordHash = localStorage.getItem("masterPasswordHash");
const sessionAuth = sessionStorage.getItem("authenticated");
const sessionTime = sessionStorage.getItem("authTime");
const sessionId = sessionStorage.getItem("sessionId");
addResult(
"credentialInfo",
`<strong>Default Username:</strong> ${MASTER_USERNAME}`,
"info"
);
addResult(
"credentialInfo",
`<strong>Default Password:</strong> hop@2026ilovejesus`,
"info"
);
if (storedUsername) {
addResult(
"credentialInfo",
`<strong>Custom Username:</strong> ${storedUsername}`,
"info"
);
} else {
addResult("credentialInfo", "(Using default username)", "info");
}
if (storedPasswordHash) {
addResult(
"credentialInfo",
`<strong>Custom Hash:</strong><br><div class="code-block">${storedPasswordHash}</div>`,
"info"
);
} else {
addResult("credentialInfo", "(Using default password)", "info");
}
addResult("credentialInfo", "<strong>Session Status:</strong>", "info");
if (sessionAuth === "true") {
addResult("credentialInfo", "✓ Currently authenticated", "pass");
if (sessionTime) {
const authDate = new Date(parseInt(sessionTime));
addResult(
"credentialInfo",
`Logged in at: ${authDate.toLocaleString()}`,
"info"
);
}
if (sessionId) {
addResult("credentialInfo", `Session ID: ${sessionId}`, "info");
}
} else {
addResult("credentialInfo", "✗ Not authenticated", "fail");
}
}
function clearAllStorage() {
if (
confirm(
"This will clear ALL localStorage and sessionStorage. Continue?"
)
) {
try {
localStorage.clear();
sessionStorage.clear();
alert("✓ All storage cleared! Reload the page to test fresh.");
} catch (e) {
alert("✗ Failed to clear storage: " + e.message);
}
}
}
// Auto-run system tests on load
window.addEventListener("load", function () {
runSystemTests();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,199 @@
/* Service Worker for Church Music Management System */
const CACHE_NAME = "church-music-v1";
const API_CACHE_NAME = "church-music-api-v1";
// Static assets to cache immediately
const STATIC_ASSETS = [
"/",
"/index.html",
"/static/css/main.css",
"/static/js/main.js",
"/favicon.ico",
"/manifest.json",
];
// API endpoints to cache with time-based expiration
const API_CACHE_DURATION = 3 * 60 * 1000; // 3 minutes
// Install event - cache static assets
self.addEventListener("install", (event) => {
console.log("[Service Worker] Installing...");
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log("[Service Worker] Caching static assets");
// Don't fail if some assets are missing
return Promise.allSettled(
STATIC_ASSETS.map((url) =>
cache
.add(url)
.catch((err) =>
console.log(`[Service Worker] Failed to cache ${url}:`, err)
)
)
);
})
.then(() => self.skipWaiting()) // Activate immediately
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
console.log("[Service Worker] Activating...");
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
.map((name) => {
console.log("[Service Worker] Deleting old cache:", name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim()) // Take control immediately
);
});
// Fetch event - serve from cache with network fallback
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) {
return;
}
// Handle API requests separately
if (url.pathname.startsWith("/api/")) {
event.respondWith(handleApiRequest(request));
return;
}
// Handle static assets with cache-first strategy
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
console.log("[Service Worker] Serving from cache:", request.url);
return cachedResponse;
}
// Not in cache, fetch from network
return fetch(request)
.then((response) => {
// Cache successful responses
if (response && response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
}
return response;
})
.catch((error) => {
console.error("[Service Worker] Fetch failed:", error);
// Return offline page if available
return caches.match("/offline.html").catch(() => {
return new Response("Offline - Please check your connection", {
status: 503,
statusText: "Service Unavailable",
headers: new Headers({
"Content-Type": "text/plain",
}),
});
});
});
})
);
});
// Handle API requests with network-first, cache-fallback strategy
async function handleApiRequest(request) {
const url = new URL(request.url);
// Only cache GET requests
if (request.method !== "GET") {
return fetch(request);
}
try {
// Try network first
const response = await fetch(request);
if (response && response.status === 200) {
// Clone and cache the response with timestamp
const responseClone = response.clone();
const cache = await caches.open(API_CACHE_NAME);
// Add timestamp header for expiration
const cachedResponse = new Response(await responseClone.blob(), {
status: responseClone.status,
statusText: responseClone.statusText,
headers: {
...Object.fromEntries(responseClone.headers.entries()),
"sw-cached-at": Date.now().toString(),
},
});
await cache.put(request, cachedResponse);
console.log("[Service Worker] Cached API response:", url.pathname);
}
return response;
} catch (error) {
console.log("[Service Worker] Network failed, trying cache:", url.pathname);
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Check if cache is still fresh
const cachedAt = cachedResponse.headers.get("sw-cached-at");
const now = Date.now();
if (cachedAt && now - parseInt(cachedAt) < API_CACHE_DURATION) {
console.log("[Service Worker] Serving fresh cached API response");
return cachedResponse;
} else {
console.log("[Service Worker] Cached API response expired");
// Return stale cache with warning header
return new Response(await cachedResponse.blob(), {
status: cachedResponse.status,
statusText: cachedResponse.statusText,
headers: {
...Object.fromEntries(cachedResponse.headers.entries()),
"sw-cache-status": "stale",
},
});
}
}
// No cache available
throw error;
}
}
// Listen for messages from the client
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
if (event.data && event.data.type === "CLEAR_CACHE") {
caches
.keys()
.then((cacheNames) => {
return Promise.all(cacheNames.map((name) => caches.delete(name)));
})
.then(() => {
event.ports[0].postMessage({ success: true });
});
}
});

View File

@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>UI Changes Test - House of Prayer</title>
<style>
body {
font-family: "Roboto", Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.test-section {
margin: 30px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border-left: 4px solid #7c3aed;
}
.test-section h2 {
margin-top: 0;
color: #7c3aed;
font-size: 1.5rem;
}
.button-demo {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 15px 0;
}
.btn-compact {
padding: 8px 12px;
font-size: 14px;
border-radius: 6px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.btn-compact:hover {
background: #f0f0f0;
border-color: #7c3aed;
}
.btn-primary {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
.btn-primary:hover {
background: #6d28d9;
}
.nav-demo {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
margin: 15px 0;
}
.nav-btn {
width: 32px;
height: 32px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.nav-counter {
padding: 4px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.key-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
margin: 15px 0;
}
.key-btn {
padding: 6px 8px;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
text-align: center;
font-weight: 600;
}
.key-btn.active {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
.status {
padding: 15px;
background: #10b981;
color: white;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin: 20px 0;
}
.info-box {
padding: 12px;
background: #dbeafe;
border-left: 4px solid #3b82f6;
border-radius: 6px;
margin: 15px 0;
}
@media (max-width: 768px) {
.container {
padding: 15px;
}
.test-section {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<h1 style="text-align: center; color: #1a1a1a">✅ UI Changes Applied!</h1>
<div class="status">Frontend is LIVE at http://192.168.10.130:3000</div>
<div class="info-box">
<strong>🎯 What Changed:</strong>
<ul style="margin: 10px 0; padding-left: 20px">
<li>Buttons are 50% smaller and more compact</li>
<li>Font sizes optimized for mobile (16px base)</li>
<li>3-column button layout instead of 2-column</li>
<li>Clean solid colors (no gradients)</li>
<li>35-40% more content visible on screen</li>
</ul>
</div>
<div class="test-section">
<h2>📱 Navigation Buttons (NEW)</h2>
<p><strong>Before:</strong> Large 48x48px circles with gradients</p>
<p><strong>After:</strong> Compact 32x32px squares</p>
<div class="nav-demo">
<button class="nav-btn"></button>
<span class="nav-counter">1 / 10</span>
<button class="nav-btn"></button>
</div>
</div>
<div class="test-section">
<h2>🎹 Key Selector (NEW)</h2>
<p><strong>Before:</strong> Large buttons with heavy borders</p>
<p><strong>After:</strong> Compact grid with minimal spacing</p>
<div class="key-grid">
<button class="key-btn">C</button>
<button class="key-btn">C#</button>
<button class="key-btn">D</button>
<button class="key-btn">D#</button>
<button class="key-btn">E</button>
<button class="key-btn">F</button>
<button class="key-btn">F#</button>
<button class="key-btn active">G</button>
<button class="key-btn">G#</button>
<button class="key-btn">A</button>
<button class="key-btn">A#</button>
<button class="key-btn">B</button>
</div>
</div>
<div class="test-section">
<h2>🎵 Action Buttons (NEW)</h2>
<p><strong>Before:</strong> 2-column grid with full text labels</p>
<p><strong>After:</strong> 3-column grid with shorter labels</p>
<div class="button-demo">
<button class="btn-compact btn-primary">🎵 Key</button>
<button class="btn-compact btn-primary">🎸 On</button>
<button class="btn-compact btn-primary">✏️</button>
<button
class="btn-compact"
style="background: #10b981; color: white; border-color: #10b981"
>
💾 Save
</button>
<button
class="btn-compact"
style="background: #ef4444; color: white; border-color: #ef4444"
>
🗑️
</button>
<button
class="btn-compact"
style="background: #6b7280; color: white; border-color: #6b7280"
>
✕ Close
</button>
</div>
</div>
<div class="test-section">
<h2>📏 Typography Changes</h2>
<table style="width: 100%; border-collapse: collapse; margin-top: 10px">
<tr style="background: #f3f4f6">
<th style="padding: 8px; text-align: left; border: 1px solid #ddd">
Element
</th>
<th style="padding: 8px; text-align: left; border: 1px solid #ddd">
Before
</th>
<th style="padding: 8px; text-align: left; border: 1px solid #ddd">
After
</th>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd">Song Title</td>
<td style="padding: 8px; border: 1px solid #ddd">24px</td>
<td
style="
padding: 8px;
border: 1px solid #ddd;
background: #dcfce7;
font-weight: 600;
"
>
16px
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd">Chords</td>
<td style="padding: 8px; border: 1px solid #ddd">24px</td>
<td
style="
padding: 8px;
border: 1px solid #ddd;
background: #dcfce7;
font-weight: 600;
"
>
15px
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd">Lyrics</td>
<td style="padding: 8px; border: 1px solid #ddd">24px</td>
<td
style="
padding: 8px;
border: 1px solid #ddd;
background: #dcfce7;
font-weight: 600;
"
>
16px
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd">
Navigation Buttons
</td>
<td style="padding: 8px; border: 1px solid #ddd">48x48px</td>
<td
style="
padding: 8px;
border: 1px solid #ddd;
background: #dcfce7;
font-weight: 600;
"
>
32x32px
</td>
</tr>
</table>
</div>
<div class="test-section">
<h2>✅ Bootstrap Status</h2>
<div
style="
padding: 15px;
background: white;
border-radius: 6px;
border: 1px solid #ddd;
"
>
<p style="margin: 5px 0">
<strong>Bootstrap 5.3.8</strong> - Installed and imported
</p>
<p style="margin: 5px 0">
<strong>Bootstrap Icons 1.13.1</strong> - Installed and imported
</p>
<p style="margin: 5px 0">
<strong>Mobile Viewport</strong> - Configured for all devices
</p>
<p style="margin: 5px 0">
<strong>Apple Mobile</strong> - iOS web app capable
</p>
<p style="margin: 5px 0">
<strong>Responsive Design</strong> - Works on all screen sizes
</p>
</div>
</div>
<div class="info-box" style="background: #fef3c7; border-color: #f59e0b">
<strong>🧪 How to Test:</strong>
<ol style="margin: 10px 0; padding-left: 20px">
<li>Open the main site: <code>http://192.168.10.130:3000</code></li>
<li>Click on any song to view it</li>
<li>Check that navigation buttons are small and compact</li>
<li>Verify key selector shows all 12 keys in a grid</li>
<li>Test transpose, chords toggle, and edit buttons</li>
<li>Try on iPhone, iPod, Android, and desktop</li>
</ol>
</div>
<div
style="
text-align: center;
margin-top: 30px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
"
>
<h3 style="margin-top: 0">🚀 Access Your Site</h3>
<p style="margin: 10px 0"><strong>Local Network:</strong></p>
<a
href="http://192.168.10.130:3000"
target="_blank"
style="
display: inline-block;
padding: 12px 24px;
background: #7c3aed;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 5px;
"
>
Open Main Site →
</a>
<p style="margin: 20px 0 10px 0"><strong>Backend API:</strong></p>
<a
href="http://192.168.10.130:8080/api/songs"
target="_blank"
style="
display: inline-block;
padding: 12px 24px;
background: #10b981;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 5px;
"
>
Test API →
</a>
</div>
</div>
<script>
// Make buttons interactive
document.querySelectorAll(".key-btn").forEach((btn) => {
btn.addEventListener("click", function () {
document
.querySelectorAll(".key-btn")
.forEach((b) => b.classList.remove("active"));
this.classList.add("active");
});
});
document.querySelectorAll(".nav-btn").forEach((btn) => {
btn.addEventListener("click", function () {
alert(
"Navigation button clicked! In the real app, this switches between songs."
);
});
});
document.querySelectorAll(".btn-compact").forEach((btn) => {
btn.addEventListener("click", function () {
alert("Button clicked: " + this.textContent);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,924 @@
import React, { useState, useEffect } from "react";
const ROLES = [
{ value: "admin", label: "Administrator", icon: "👑" },
{ value: "worship_leader", label: "Worship Leader", icon: "🎤" },
{ value: "lead_singer", label: "Lead Singer - Lead Vocal", icon: "🎵" },
{ value: "bass_guitar", label: "Bass Guitar", icon: "🎸" },
{ value: "piano", label: "Piano", icon: "🎹" },
{ value: "acoustic", label: "Acoustic Guitar", icon: "🎸" },
{ value: "viewer", label: "Viewer", icon: "👁️" },
];
const PERMISSIONS = [
{ value: "view", label: "View", description: "View songs and worship lists" },
{ value: "edit", label: "Edit", description: "Edit songs and worship lists" },
{
value: "modify",
label: "Modify",
description: "Create/Delete songs and worship lists",
},
{
value: "settings",
label: "Settings",
description: "Access settings and profiles",
},
];
function AdminPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(true);
const [notification, setNotification] = useState(null);
const [biometricSetupUser, setBiometricSetupUser] = useState(null);
const [biometricLoading, setBiometricLoading] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
// Form state
const [formData, setFormData] = useState({
username: "",
password: "",
role: "viewer",
permissions: ["view"],
active: true,
has_biometric: false,
});
// Show notification helper
function showNotification(message, type = "success") {
setNotification({ message, type });
setTimeout(() => setNotification(null), 3000);
}
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
try {
const response = await fetch("/api/auth/check", {
credentials: "include",
}).catch(() => {
// Silently catch network errors
return { ok: false, status: 401 };
});
if (response.ok) {
const data = await response.json();
console.log("[AdminPage] Auth check response:", data);
console.log(
"[AdminPage] Has settings permission:",
data.permissions?.includes("settings")
);
console.log("[AdminPage] Is admin:", data.role === "admin");
if (
data.authenticated &&
(data.role === "admin" ||
(data.permissions && data.permissions.includes("settings")))
) {
setIsAuthenticated(true);
setCurrentUser({
username: data.username,
role: data.role,
permissions: data.permissions,
});
loadUsers();
} else if (data.authenticated) {
console.error(
"[AdminPage] Access denied. Role:",
data.role,
"Permissions:",
data.permissions
);
setError(
"Admin access required. You need 'Settings' permission to access this page."
);
setCheckingAuth(false);
} else {
setError("Please login first to access the admin panel.");
setCheckingAuth(false);
}
} else {
// 401 is expected when not logged in - don't log it
setError("Please login first to access the admin panel.");
setCheckingAuth(false);
}
} catch (err) {
// Silently handle auth check failures - they're expected when not logged in
setError("Please login first to access the admin panel.");
setCheckingAuth(false);
}
}
async function loadUsers() {
try {
setLoading(true);
setError(null);
const response = await fetch("/api/users", {
credentials: "include", // Include session cookies
});
if (response.status === 401) {
setError("Not logged in. Please login first.");
return;
}
if (response.status === 403) {
setError(
"Admin access required. You must be an administrator to access this page."
);
return;
}
if (response.ok) {
const data = await response.json();
// Fetch biometric status for each user
const usersWithBiometric = await Promise.all(
data.map(async (user) => {
try {
const bioResponse = await fetch(
`/api/biometric/status/${user.username}`,
{
credentials: "include",
}
);
if (bioResponse.ok) {
const bioData = await bioResponse.json();
return {
...user,
has_biometric: bioData.has_biometric || false,
};
}
} catch (err) {
console.error(
`Failed to get biometric status for ${user.username}:`,
err
);
}
return { ...user, has_biometric: false };
})
);
setUsers(usersWithBiometric);
} else {
const err = await response.json();
setError(err.message || err.error || "Failed to load users");
}
} catch (err) {
console.error("Load users error:", err);
setError("Failed to connect to server");
} finally {
setLoading(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
try {
const url = editingUser ? `/api/users/${editingUser.id}` : "/api/users";
const method = editingUser ? "PUT" : "POST";
const payload = {
...formData,
permissions: formData.permissions,
};
// Don't send password if empty during edit
if (editingUser && !formData.password) {
delete payload.password;
}
// Check if username changed and user has biometric
const usernameChanged =
editingUser && editingUser.username !== formData.username;
const hadBiometric = editingUser?.has_biometric;
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
credentials: "include", // Include session cookies
body: JSON.stringify(payload),
});
if (response.ok) {
const savedUser = await response.json();
// If username changed and user had biometric, update biometric credentials
if (usernameChanged && hadBiometric) {
showNotification(
"Username updated. Setting up biometric for new username...",
"success"
);
try {
// Update biometric registration for the new username
const biometricAuth = await import("./biometricAuth");
const result = await biometricAuth.registerBiometric(
formData.username
);
if (result.success) {
showNotification(
`User updated and biometric credentials transferred to new username!`,
"success"
);
} else {
showNotification(
`User updated, but biometric setup failed. Please click Setup button to re-enable biometric.`,
"error"
);
}
} catch (biometricErr) {
console.error("Biometric update error:", biometricErr);
showNotification(
`User updated, but biometric setup failed: ${biometricErr.message}. Please click Setup button.`,
"error"
);
}
} else {
showNotification(
editingUser
? "User updated successfully!"
: "User created successfully!",
"success"
);
}
await loadUsers();
closeModal();
} else {
const err = await response.json();
showNotification(
err.message || err.error || "Failed to save user",
"error"
);
}
} catch (err) {
console.error("Save user error:", err);
showNotification("Failed to save user", "error");
}
}
async function handleDelete(userId) {
if (!deleteConfirm) return;
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE",
credentials: "include", // Include session cookies
});
if (response.ok) {
await loadUsers();
setDeleteConfirm(null);
showNotification("User deleted successfully!", "success");
} else {
const err = await response.json();
showNotification(
err.message || err.error || "Failed to delete user",
"error"
);
}
} catch (err) {
console.error("Delete user error:", err);
showNotification("Failed to delete user", "error");
}
}
function openCreateModal() {
setEditingUser(null);
setFormData({
username: "",
password: "",
role: "viewer",
permissions: ["view"],
active: true,
});
setShowCreateModal(true);
}
function openEditModal(user) {
setEditingUser(user);
setFormData({
username: user.username,
password: "",
role: user.role,
permissions: user.permissions || ["view"],
active: user.active,
has_biometric: user.has_biometric || false,
});
setShowCreateModal(true);
}
async function handleBiometricSetup(user) {
setBiometricSetupUser(user);
setBiometricLoading(true);
try {
const biometricAuth = await import("./biometricAuth");
// Check if biometric is available
const availability = await biometricAuth.isBiometricAvailable();
if (!availability.available) {
showNotification(
`Biometric not available: ${availability.reason}`,
"error"
);
setBiometricSetupUser(null);
setBiometricLoading(false);
return;
}
// Register biometric for this user
const result = await biometricAuth.registerBiometric(user.username);
if (result.success) {
showNotification(
`Biometric authentication set up for ${user.username}! \ud83d\udc4d`,
"success"
);
await loadUsers(); // Reload to show updated biometric status
} else {
showNotification(
`Failed to set up biometric: ${result.error}`,
"error"
);
}
} catch (err) {
console.error("Biometric setup error:", err);
showNotification(`Biometric setup failed: ${err.message}`, "error");
} finally {
setBiometricSetupUser(null);
setBiometricLoading(false);
}
}
async function handleBiometricRemove(user) {
if (
!window.confirm(`Remove biometric authentication for ${user.username}?`)
) {
return;
}
setBiometricSetupUser(user);
setBiometricLoading(true);
try {
const biometricAuth = await import("./biometricAuth");
const result = await biometricAuth.removeBiometric(user.username);
if (result.success) {
showNotification(`Biometric removed for ${user.username}`, "success");
await loadUsers(); // Reload to show updated status
} else {
showNotification(
`Failed to remove biometric: ${result.error}`,
"error"
);
}
} catch (err) {
console.error("Biometric remove error:", err);
showNotification(`Failed to remove biometric: ${err.message}`, "error");
} finally {
setBiometricSetupUser(null);
setBiometricLoading(false);
}
}
function closeModal() {
setShowCreateModal(false);
setEditingUser(null);
setFormData({
username: "",
password: "",
role: "viewer",
permissions: ["view"],
active: true,
});
}
function togglePermission(perm) {
if (formData.permissions.includes(perm)) {
setFormData({
...formData,
permissions: formData.permissions.filter((p) => p !== perm),
});
} else {
setFormData({
...formData,
permissions: [...formData.permissions, perm],
});
}
}
const getRoleInfo = (role) => {
return ROLES.find((r) => r.value === role) || ROLES[ROLES.length - 1];
};
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Notification Toast */}
{notification && (
<div
className="fixed top-20 right-4 z-[9999] animate-slide-in"
style={{ minWidth: "320px" }}
>
<div
className={`${
notification.type === "success"
? "bg-green-50 border-green-400 text-green-800"
: "bg-red-50 border-red-400 text-red-800"
} border-2 rounded-lg shadow-lg px-4 py-3 flex items-center justify-between`}
>
<div className="flex items-center">
<span className="text-2xl mr-3">
{notification.type === "success" ? "✓" : "⚠"}
</span>
<strong className="font-semibold">{notification.message}</strong>
</div>
<button
onClick={() => setNotification(null)}
className="ml-4 text-2xl font-bold hover:opacity-70"
>
×
</button>
</div>
</div>
)}
<style>{`
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`}</style>
<div className="mb-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-2">
👑 Admin Panel
</h1>
<p className="text-gray-600">
Manage users, roles, and permissions
</p>
</div>
{currentUser && (
<div className="bg-gradient-to-br from-purple-50 to-blue-50 border-2 border-purple-200 rounded-xl p-4 shadow-md min-w-[280px]">
<div className="flex items-center gap-3">
<div className="bg-purple-600 rounded-full w-12 h-12 flex items-center justify-center text-white text-xl font-bold shadow-lg">
{currentUser.username.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm text-gray-600 font-medium">
Welcome back
</p>
<p className="text-xl font-bold text-gray-800">
{currentUser.username}
</p>
<p className="text-xs text-purple-600 font-semibold mt-0.5">
{getRoleInfo(currentUser.role).icon}{" "}
{getRoleInfo(currentUser.role).label}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{checkingAuth ? (
<div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg mb-6">
<strong>Checking authentication...</strong>
</div>
) : !isAuthenticated ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
<h3 className="text-xl font-bold text-yellow-800 mb-3">
🔒 Authentication Required
</h3>
<p className="text-yellow-700 mb-4">{error}</p>
<a
href="/"
className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold"
>
Go to Login Page
</a>
</div>
) : null}
{error && isAuthenticated && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-6">
<strong>Error:</strong> {error}
</div>
)}
{isAuthenticated && (
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-800">Users</h2>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold flex items-center gap-2"
>
<span></span> Create User
</button>
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">
Loading users...
</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-gray-500">No users found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left p-3 font-semibold text-gray-700">
Username
</th>
<th className="text-left p-3 font-semibold text-gray-700">
Role
</th>
<th className="text-left p-3 font-semibold text-gray-700">
Permissions
</th>
<th className="text-left p-3 font-semibold text-gray-700">
Status
</th>
<th className="text-left p-3 font-semibold text-gray-700">
Last Login
</th>
<th className="text-right p-3 font-semibold text-gray-700">
Actions
</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const roleInfo = getRoleInfo(user.role);
return (
<tr
key={user.id}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="p-3">
<div className="font-semibold text-gray-800">
{user.username}
</div>
</td>
<td className="p-3">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-semibold">
<span>{roleInfo.icon}</span>
{roleInfo.label}
</span>
</td>
<td className="p-3">
<div className="flex flex-wrap gap-1">
{user.permissions && user.permissions.length > 0 ? (
user.permissions.map((perm) => (
<span
key={perm}
className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-semibold"
>
{perm}
</span>
))
) : (
<span className="text-gray-400 text-sm">
None
</span>
)}
</div>
</td>
<td className="p-3">
{user.active ? (
<span className="text-green-600 font-semibold">
Active
</span>
) : (
<span className="text-red-600 font-semibold">
Disabled
</span>
)}
</td>
<td className="p-3 text-sm text-gray-600">
{user.last_login
? new Date(user.last_login).toLocaleDateString()
: "Never"}
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm"
title="Edit User"
>
Edit
</button>
{user.has_biometric ? (
<button
onClick={() => handleBiometricRemove(user)}
disabled={
biometricLoading &&
biometricSetupUser?.id === user.id
}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-sm"
title="Remove Biometric"
>
{biometricLoading &&
biometricSetupUser?.id === user.id
? "⏳"
: "🔐 Remove"}
</button>
) : (
<button
onClick={() => handleBiometricSetup(user)}
disabled={
biometricLoading &&
biometricSetupUser?.id === user.id
}
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded transition-colors text-sm"
title="Setup Biometric"
>
{biometricLoading &&
biometricSetupUser?.id === user.id
? "⏳"
: "🔐 Setup"}
</button>
)}
{user.role !== "admin" && (
<button
onClick={() => setDeleteConfirm(user)}
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-sm"
title="Delete User"
>
🗑 Delete
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Create/Edit Modal */}
{showCreateModal && (
<div
onClick={closeModal}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
padding: "20px",
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto"
>
<h3 className="text-2xl font-bold text-gray-800 mb-4">
{editingUser ? "✏️ Edit User" : " Create New User"}
</h3>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Username
</label>
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
{editingUser && formData.has_biometric && (
<p className="text-xs text-blue-600 mt-1">
Changing username will automatically update biometric
credentials
</p>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Password
{editingUser && (
<span className="text-gray-500 font-normal ml-2">
(leave blank to keep current password)
</span>
)}
</label>
{editingUser && (
<div className="mb-2 space-y-2">
<div className="p-2 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2 text-sm">
<span className="text-green-600">🔒</span>
<span className="text-green-700 font-medium">
Password is set and secure
</span>
</div>
{formData.has_biometric ? (
<div className="p-2 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2 text-sm">
<span className="text-blue-600">🔐</span>
<span className="text-blue-700 font-medium">
Biometric authentication enabled
</span>
</div>
) : (
<div className="p-2 bg-gray-50 border border-gray-200 rounded-lg flex items-center gap-2 text-sm">
<span className="text-gray-600">🔐</span>
<span className="text-gray-700 font-medium">
Biometric not set up yet - Click "Setup" button in
user list
</span>
</div>
)}
</div>
)}
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
required={!editingUser}
minLength={8}
placeholder={
editingUser
? "Enter new password to change"
: "Enter password"
}
/>
<p className="text-xs text-gray-500 mt-1">
Minimum 8 characters
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Role
</label>
<select
value={formData.role}
onChange={(e) =>
setFormData({ ...formData, role: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{ROLES.map((role) => (
<option key={role.value} value={role.value}>
{role.icon} {role.label}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Permissions
</label>
<div className="space-y-2">
{PERMISSIONS.map((perm) => (
<label
key={perm.value}
className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100"
>
<input
type="checkbox"
checked={formData.permissions.includes(perm.value)}
onChange={() => togglePermission(perm.value)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-800">
{perm.label}
</div>
<div className="text-sm text-gray-600">
{perm.description}
</div>
</div>
</label>
))}
</div>
</div>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.active}
onChange={(e) =>
setFormData({ ...formData, active: e.target.checked })
}
/>
<span className="font-semibold text-gray-800">
Account Active
</span>
</label>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={closeModal}
className="px-6 py-2 bg-gray-300 text-gray-800 rounded-lg hover:bg-gray-400 transition-colors font-semibold"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold"
>
{editingUser ? "Update User" : "Create User"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div
onClick={() => setDeleteConfirm(null)}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
padding: "20px",
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl p-6 w-full max-w-md"
>
<h3 className="text-2xl font-bold text-red-600 mb-4">
Delete User
</h3>
<p className="text-gray-700 mb-6">
Are you sure you want to delete user{" "}
<strong>{deleteConfirm.username}</strong>? This action cannot be
undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirm(null)}
className="px-6 py-2 bg-gray-300 text-gray-800 rounded-lg hover:bg-gray-400 transition-colors font-semibold"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirm.id)}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-semibold"
>
Delete User
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default AdminPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import CryptoJS from 'crypto-js';
const MASTER_USERNAME = "hop";
const MASTER_PASSWORD_HASH = "5cdf907c69ae7a7f0c2e18a67e9b70a4c4fc35f9582637354c1bc45edf092a79";
function LoginSimple({ onLoginSuccess }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
console.log("LoginSimple: Form submitted");
const inputHash = CryptoJS.SHA256(password).toString();
console.log("Username:", username, "| Match:", username === MASTER_USERNAME);
console.log("Hash match:", inputHash === MASTER_PASSWORD_HASH);
if (username === MASTER_USERNAME && inputHash === MASTER_PASSWORD_HASH) {
sessionStorage.setItem("authenticated", "true");
sessionStorage.setItem("authTime", Date.now().toString());
onLoginSuccess();
} else {
setError("Invalid username or password");
}
};
return (
<div style={{ maxWidth: '400px', margin: '100px auto', padding: '20px', border: '1px solid #ccc' }}>
<h2>Simple Login Test</h2>
<form onSubmit={handleSubmit}>
{error && <div style={{ color: 'red', marginBottom: '10px' }}>{error}</div>}
<div style={{ marginBottom: '10px' }}>
<label>Username:</label><br/>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
style={{ width: '100%', padding: '8px' }}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<label>Password:</label><br/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ width: '100%', padding: '8px' }}
/>
</div>
<button type="submit" style={{ width: '100%', padding: '10px', background: '#667eea', color: 'white', border: 'none', cursor: 'pointer' }}>
Login (Press Enter or Click)
</button>
</form>
<p style={{ marginTop: '20px', fontSize: '12px', color: '#666' }}>
Credentials: hop / hop@2026ilovejesus<br/>
Press Enter in any field to submit
</p>
</div>
);
}
export default LoginSimple;

View File

@@ -0,0 +1,915 @@
import { localStorageAPI } from "./localStorage";
// Load settings from localStorage or fallback to defaults
function getAPISettings() {
const stored = localStorage.getItem("api_settings");
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error("Failed to parse API settings, resetting to defaults:", e);
localStorage.removeItem("api_settings");
}
}
// Auto-detect hostname from current URL
const currentHost = window.location.hostname;
const isLocal = currentHost === "localhost" || currentHost === "127.0.0.1";
return {
protocol: window.location.protocol.replace(":", ""), // Use same protocol as page (http/https)
hostname: isLocal ? "localhost" : currentHost,
port: isLocal ? "8080" : "", // No port when using DNS (Nginx handles routing)
useLocalStorage: false,
};
}
function getAPIBase() {
const settings = getAPISettings();
if (settings.useLocalStorage) return null;
// Build API URL - only include port if specified
const portPart = settings.port ? `:${settings.port}` : "";
return `${settings.protocol}://${settings.hostname}${portPart}/api`;
}
// Auto-switch to Backend Mode when API is reachable to avoid stuck Offline
async function ensureBackendMode() {
try {
const settings = getAPISettings();
const portPart = settings.port ? `:${settings.port}` : "";
const apiHost = `${settings.protocol}://${settings.hostname}${portPart}`;
const res = await fetch(`${apiHost}/api/health`, { method: "GET" });
if (res.ok && settings.useLocalStorage) {
const fixed = { ...settings, useLocalStorage: false };
localStorage.setItem("api_settings", JSON.stringify(fixed));
// Notify app to refresh data sources
window.dispatchEvent(new CustomEvent("settingsFixed", { detail: fixed }));
}
} catch (_) {
// ignore if unreachable; app can still operate in local mode
}
}
// Run on module load
ensureBackendMode();
// Real-time sync via polling (WebSocket removed - Flask doesn't support WebSockets by default)
// Components will use polling intervals to check for updates
// Quick backend reachability check
export async function pingBackend(timeoutMs = 3000) {
const settings = getAPISettings();
if (settings.useLocalStorage) return { online: false };
const API_BASE = getAPIBase();
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_BASE}/health`, {
signal: controller.signal,
});
clearTimeout(t);
return { online: res.ok };
} catch (e) {
clearTimeout(t);
return { online: false, error: e.message };
}
}
// Manual sync trigger: emit event so views can refresh
export async function syncNow() {
try {
window.dispatchEvent(new CustomEvent("syncNow"));
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
export async function fetchProfiles() {
const settings = getAPISettings();
const API_BASE = getAPIBase();
// Always include local profiles; merge with backend when online
const localProfiles = await localStorageAPI.getProfiles();
if (settings.useLocalStorage) {
return localProfiles;
}
try {
// Use ETag for conditional requests to reduce bandwidth
const lastETag = sessionStorage.getItem("profiles_etag");
const headers = {
credentials: "include",
};
if (lastETag) {
headers["If-None-Match"] = lastETag;
}
const res = await fetch(`${API_BASE}/profiles`, headers);
// If 304 Not Modified, return cached data
if (res.status === 304) {
const cached = sessionStorage.getItem("profiles_cached");
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
// Fall through to refetch
}
}
}
// Store ETag for next request
const etag = res.headers.get("ETag");
if (etag) {
sessionStorage.setItem("profiles_etag", etag);
}
// Check for authentication error
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return localProfiles;
}
const backendProfiles = res.ok ? await res.json() : [];
// Cache response
if (backendProfiles.length > 0) {
sessionStorage.setItem(
"profiles_cached",
JSON.stringify(backendProfiles)
);
}
// ID-based deduplication (more reliable than name-based)
const backendIds = new Set(backendProfiles.map((p) => p.id));
const extra = localProfiles.filter((lp) => !backendIds.has(lp.id));
// Sync backend profiles to localStorage in parallel (faster & error-resilient)
await Promise.allSettled(
backendProfiles.map((profile) =>
localStorageAPI.updateProfile(profile.id, profile).catch(() => null)
)
);
return [...backendProfiles, ...extra];
} catch (err) {
return localProfiles;
}
}
export async function createProfile(payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.createProfile(payload);
}
try {
const res = await fetch(`${API_BASE}/profiles`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.createProfile(payload);
}
const created = res.ok ? await res.json() : null;
if (!created) {
return await localStorageAPI.createProfile(payload);
}
// Sync to localStorage with backend ID
await localStorageAPI.createProfile(created);
// Invalidate cache
sessionStorage.removeItem("profiles_etag");
sessionStorage.removeItem("profiles_cached");
// Dispatch event to update all components
window.dispatchEvent(new Event("profileChanged"));
return created;
} catch (err) {
return await localStorageAPI.createProfile(payload);
}
}
export async function updateProfile(id, payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.updateProfile(id, payload);
}
try {
const res = await fetch(`${API_BASE}/profiles/${id}`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.updateProfile(id, payload);
}
const updated = res.ok ? await res.json() : null;
if (!updated) {
return await localStorageAPI.updateProfile(id, payload);
}
console.log("[updateProfile] Updated in backend:", id);
await localStorageAPI.updateProfile(id, updated);
console.log("[updateProfile] Synced to localStorage");
// Invalidate cache
sessionStorage.removeItem("profiles_etag");
sessionStorage.removeItem("profiles_cached");
// Dispatch event to update all components
window.dispatchEvent(new Event("profileChanged"));
return updated;
} catch (err) {
console.error("[updateProfile] Error:", err);
return await localStorageAPI.updateProfile(id, payload);
}
}
export async function deleteProfile(id) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.deleteProfile(id);
}
try {
const res = await fetch(`${API_BASE}/profiles/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.deleteProfile(id);
}
// Always sync to localStorage regardless of backend response
await localStorageAPI.deleteProfile(id);
// Invalidate cache
sessionStorage.removeItem("profiles_etag");
sessionStorage.removeItem("profiles_cached");
// Dispatch event to update all components
window.dispatchEvent(new Event("profileChanged"));
return res.ok ? await res.json() : { ok: true };
} catch (err) {
return await localStorageAPI.deleteProfile(id);
}
}
export async function searchLocalSongs(q = "") {
const settings = getAPISettings();
const API_BASE = getAPIBase();
const localSongs = await localStorageAPI.getSongs(q);
if (settings.useLocalStorage) {
return localSongs;
}
try {
const res = await fetch(`${API_BASE}/songs?q=${encodeURIComponent(q)}`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return localSongs;
}
const backendSongs = res.ok ? await res.json() : [];
// Dedupe by title+artist
const key = (s) =>
`${(s.title || "").trim().toLowerCase()}|${(s.artist || "")
.trim()
.toLowerCase()}`;
const seen = new Set(backendSongs.map(key));
const extra = localSongs.filter((s) => !seen.has(key(s)));
return [...backendSongs, ...extra];
} catch (err) {
return localSongs;
}
}
export async function getSong(id) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.getSong(id);
}
try {
const res = await fetch(`${API_BASE}/songs/${id}`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.getSong(id);
}
if (!res.ok) throw new Error("API Error");
return res.json();
} catch (err) {
return await localStorageAPI.getSong(id);
}
}
// Fetch song from both sources and merge fields
export async function getSongMerged(id) {
const settings = getAPISettings();
if (settings.useLocalStorage) {
return await localStorageAPI.getSong(id);
}
const API_BASE = getAPIBase();
let backend = null;
try {
const res = await fetch(`${API_BASE}/songs/${id}`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.getSong(id);
}
backend = res.ok ? await res.json() : null;
} catch (e) {}
const local = await localStorageAPI.getSong(id);
if (!backend) return local;
if (!local) return backend;
// Merge with preference for non-empty fields
return {
id: backend.id || local.id,
title: backend.title || local.title || "",
artist: backend.artist || local.artist || "",
band: backend.band || local.band || "",
lyrics: backend.lyrics || local.lyrics || "",
chords: backend.chords || local.chords || "",
singer: backend.singer || local.singer || "",
created_at: backend.created_at || local.created_at,
updated_at: backend.updated_at || local.updated_at,
};
}
export async function saveSong(id, payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.updateSong(id, payload);
}
try {
const res = await fetch(`${API_BASE}/songs/${id}`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.updateSong(id, payload);
}
const updated = res.ok ? await res.json() : null;
await localStorageAPI.updateSong(id, updated ?? payload);
return updated ?? payload;
} catch (err) {
return await localStorageAPI.updateSong(id, payload);
}
}
export async function deleteSong(id) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.deleteSong(id);
}
try {
const res = await fetch(`${API_BASE}/songs/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.deleteSong(id);
}
await localStorageAPI.deleteSong(id);
return res.ok ? await res.json() : { ok: true };
} catch (err) {
return await localStorageAPI.deleteSong(id);
}
}
export async function createSong(payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.createSong(payload);
}
try {
const res = await fetch(`${API_BASE}/songs`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.createSong(payload);
}
const created = res.ok ? await res.json() : null;
await localStorageAPI.createSong(created ?? payload);
return created ?? payload;
} catch (err) {
return await localStorageAPI.createSong(payload);
}
}
export async function searchExternal(q = "", filterType = "all") {
const API_BASE = getAPIBase();
const res = await fetch(
`${API_BASE}/external/search?q=${encodeURIComponent(
q
)}&filter=${filterType}`,
{ credentials: "include" }
);
return res.json();
}
export async function searchEssentialWorship(q = "") {
const API_BASE = getAPIBase();
const res = await fetch(
`${API_BASE}/essentialworship/search?q=${encodeURIComponent(q)}`
);
return res.json();
}
export async function fetchEssentialWorshipLyrics(url = "") {
const API_BASE = getAPIBase();
const res = await fetch(
`${API_BASE}/essentialworship/lyrics?url=${encodeURIComponent(url)}`
);
return res.json();
}
export async function fetchPlans() {
const settings = getAPISettings();
const API_BASE = getAPIBase();
const localPlans = await localStorageAPI.getPlans();
if (settings.useLocalStorage) {
return localPlans;
}
try {
// Add cache busting to ensure fresh data
const res = await fetch(`${API_BASE}/plans?_t=${Date.now()}`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return localPlans;
}
const backendPlans = res.ok ? await res.json() : [];
// Use ID-based deduplication instead of date+notes to prevent duplicates
const planMap = new Map();
// Backend plans take priority
backendPlans.forEach((p) => {
if (p.id) planMap.set(p.id, p);
});
// Only add local plans that don't exist in backend
localPlans.forEach((p) => {
if (p.id && !planMap.has(p.id)) {
planMap.set(p.id, p);
}
});
return Array.from(planMap.values());
} catch (err) {
return localPlans;
}
}
export async function createPlan(payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.createPlan(payload);
}
try {
const res = await fetch(`${API_BASE}/plans`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.createPlan(payload);
}
const created = res.ok ? await res.json() : null;
await localStorageAPI.createPlan(created ?? payload);
return created ?? payload;
} catch (err) {
return await localStorageAPI.createPlan(payload);
}
}
export async function fetchPlanSongs(planId) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
const local = await localStorageAPI.getPlanSongs(planId);
if (settings.useLocalStorage) {
return local;
}
try {
const res = await fetch(`${API_BASE}/plans/${planId}/songs`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return local;
}
const backend = res.ok ? await res.json() : [];
// Dedupe by song_id+order_index
const key = (ps) => `${ps.song_id}|${ps.order_index}`;
const seen = new Set(backend.map(key));
const extra = local.filter((ps) => !seen.has(key(ps)));
return [...backend, ...extra].sort(
(a, b) => (a.order_index || 0) - (b.order_index || 0)
);
} catch (err) {
return local;
}
}
export async function addSongToPlan(planId, payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.addSongToPlan(planId, payload);
}
try {
const url = `${API_BASE}/plans/${planId}/songs`;
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.addSongToPlan(planId, payload);
}
const created = res.ok ? await res.json() : null;
// Mirror to local
await localStorageAPI.addSongToPlan(planId, payload);
return created ?? payload;
} catch (err) {
return await localStorageAPI.addSongToPlan(planId, payload);
}
}
export async function updatePlan(id, payload) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.updatePlan(id, payload);
}
try {
const url = `${API_BASE}/plans/${id}`;
const res = await fetch(url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.updatePlan(id, payload);
}
const updated = res.ok ? await res.json() : null;
await localStorageAPI.updatePlan(id, updated ?? payload);
return updated ?? payload;
} catch (err) {
return await localStorageAPI.updatePlan(id, payload);
}
}
export async function deletePlan(id) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.deletePlan(id);
}
try {
const res = await fetch(`${API_BASE}/plans/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
await localStorageAPI.deletePlan(id);
return { ok: true };
}
if (!res.ok) {
throw new Error("Backend delete failed");
}
await localStorageAPI.deletePlan(id);
return await res.json();
} catch (err) {
// If backend fails, still try to delete from localStorage
await localStorageAPI.deletePlan(id);
return { ok: true };
}
}
export async function uploadLyricFile(file, meta = {}) {
const API_BASE = getAPIBase();
const fd = new FormData();
fd.append("file", file);
if (meta.title) fd.append("title", meta.title);
if (meta.artist) fd.append("artist", meta.artist);
if (meta.band) fd.append("band", meta.band);
const res = await fetch(`${API_BASE}/upload_lyric`, {
method: "POST",
credentials: "include",
body: fd,
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
throw new Error("Authentication required");
}
return res.json();
}
export function exportPlanURL(planId) {
const API_BASE = getAPIBase();
return `${API_BASE}/export/${planId}`;
}
export async function getProfileSongs(profileId) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
const local = await localStorageAPI.getProfileSongs(profileId);
if (settings.useLocalStorage) {
return local;
}
try {
const res = await fetch(`${API_BASE}/profiles/${profileId}/songs`, {
credentials: "include",
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return local;
}
const backend = res.ok ? await res.json() : [];
// NEW: Backend now returns full song objects directly (optimized single query)
// No need for individual song fetches - massive performance improvement!
if (backend.length && backend[0] && backend[0].title) {
// Backend already returns complete song data with keys
const key = (s) => s.id;
const seen = new Set(backend.map(key));
const extra = local.filter((s) => !seen.has(key(s)));
return [...backend, ...extra];
}
// Fallback: Old format (associations only) - for backwards compatibility
if (backend.length && backend[0] && backend[0].song_id) {
const fullSongs = [];
for (const ps of backend) {
let song = await localStorageAPI.getSong(ps.song_id);
if (!song) {
try {
const r = await fetch(`${API_BASE}/songs/${ps.song_id}`);
if (r.ok) song = await r.json();
} catch {}
}
if (song) fullSongs.push(song);
}
const seen = new Set(fullSongs.map((s) => s.id));
const extra = local.filter((s) => !seen.has(s.id));
return [...fullSongs, ...extra];
}
// Empty or merge with local
const key = (s) => s.id;
const seen = new Set(backend.map(key));
const extra = local.filter((s) => !seen.has(key(s)));
return [...backend, ...extra];
} catch (err) {
return local;
}
}
export async function addSongToProfile(profileId, songId) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.addSongToProfile(profileId, songId);
}
try {
const res = await fetch(`${API_BASE}/profiles/${profileId}/songs`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ song_id: songId }),
});
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.addSongToProfile(profileId, songId);
}
const created = res.ok ? await res.json() : null;
await localStorageAPI.addSongToProfile(profileId, songId);
return created ?? { profile_id: profileId, song_id: songId };
} catch (err) {
return await localStorageAPI.addSongToProfile(profileId, songId);
}
}
export async function removeSongFromProfile(profileId, songId) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.removeSongFromProfile(profileId, songId);
}
try {
const res = await fetch(
`${API_BASE}/profiles/${profileId}/songs/${songId}`,
{
method: "DELETE",
credentials: "include",
}
);
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.removeSongFromProfile(profileId, songId);
}
await localStorageAPI.removeSongFromProfile(profileId, songId);
return res.ok ? await res.json() : { ok: true };
} catch (err) {
return await localStorageAPI.removeSongFromProfile(profileId, songId);
}
}
export async function getProfileSongKey(profileId, songId) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.getProfileSongKey(profileId, songId);
}
try {
const res = await fetch(
`${API_BASE}/profiles/${profileId}/songs/${songId}/key`,
{ credentials: "include" }
);
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.getProfileSongKey(profileId, songId);
}
const data = res.ok ? await res.json() : { key: null };
// Prefer backend key, fallback to local
const localKey = await localStorageAPI.getProfileSongKey(profileId, songId);
return data.key ?? localKey;
} catch (err) {
const localKey = await localStorageAPI.getProfileSongKey(profileId, songId);
return localKey;
}
}
export async function saveProfileSongKey(profileId, songId, key) {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
return await localStorageAPI.saveProfileSongKey(profileId, songId, key);
}
try {
const res = await fetch(
`${API_BASE}/profiles/${profileId}/songs/${songId}/key`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}
);
if (res.status === 401) {
window.dispatchEvent(
new CustomEvent("authError", { detail: "Authentication required" })
);
return await localStorageAPI.saveProfileSongKey(profileId, songId, key);
}
const updated = res.ok ? await res.json() : { key };
await localStorageAPI.saveProfileSongKey(profileId, songId, updated.key);
return updated;
} catch (err) {
return await localStorageAPI.saveProfileSongKey(profileId, songId, key);
}
}
export async function clearProfileSelection() {
const settings = getAPISettings();
const API_BASE = getAPIBase();
if (settings.useLocalStorage) {
// In offline mode, just dispatch local event
window.dispatchEvent(new CustomEvent("profileSelectionCleared"));
return;
}
try {
await fetch(`${API_BASE}/profile-selection/clear`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
} catch (err) {
// Still dispatch locally even if server call fails
window.dispatchEvent(new CustomEvent("profileSelectionCleared"));
}
}

View File

@@ -0,0 +1,415 @@
/**
* Biometric Authentication - Store credentials and unlock with fingerprint
* This is NOT WebAuthn passkeys - it's using biometric to unlock stored username
*/
const API_BASE = window.location.origin;
/**
* Check if biometric authentication is available on this device
*/
export async function isBiometricAvailable() {
// Check if we're on HTTPS or localhost
const isSecureContext = window.isSecureContext;
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
if (!isSecureContext && !isLocalhost) {
return {
available: false,
reason:
"HTTPS required for biometric authentication. Please access via https://",
};
}
if (!window.PublicKeyCredential) {
return {
available: false,
reason: "Biometric authentication not supported by this browser",
};
}
try {
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) {
return {
available: false,
reason: "No biometric authenticator found on this device",
};
}
return { available: true, type: "biometric" };
} catch (error) {
console.error("Error checking biometric availability:", error);
return { available: false, reason: error.message };
}
}
/**
* Get device information for identification
*/
function getDeviceInfo() {
const ua = navigator.userAgent;
let deviceName = "Unknown Device";
if (/iPhone/.test(ua)) deviceName = "iPhone";
else if (/iPad/.test(ua)) deviceName = "iPad";
else if (/Android/.test(ua)) deviceName = "Android Device";
else if (/Windows/.test(ua)) deviceName = "Windows PC";
else if (/Mac/.test(ua)) deviceName = "Mac";
else if (/Linux/.test(ua)) deviceName = "Linux PC";
return {
name: deviceName,
info: ua,
};
}
/**
* Generate a simple device fingerprint for this browser/device
* Robust for mobile devices with proper fallbacks
*/
function generateDeviceFingerprint() {
try {
// Collect device characteristics with fallbacks for mobile
const characteristics = [
navigator.userAgent || "unknown-ua",
navigator.language || navigator.userLanguage || "unknown-lang",
(screen.width || 0) + "x" + (screen.height || 0),
screen.colorDepth || "unknown-color",
new Date().getTimezoneOffset() || 0,
!!window.sessionStorage,
!!window.localStorage,
navigator.platform || "unknown-platform",
navigator.hardwareConcurrency || "unknown-cpu",
screen.pixelDepth || "unknown-depth",
].join("|");
console.log(
"[Biometric] Generating fingerprint from characteristics:",
characteristics
);
// Simple hash function
let hash = 0;
for (let i = 0; i < characteristics.length; i++) {
const char = characteristics.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
const fingerprint = Math.abs(hash).toString(36);
console.log("[Biometric] Generated fingerprint:", fingerprint);
if (!fingerprint || fingerprint === "0") {
throw new Error("Failed to generate valid fingerprint");
}
return fingerprint;
} catch (error) {
console.error("[Biometric] Error generating fingerprint:", error);
// Fallback: Use timestamp + random number for unique identifier
const fallbackFingerprint =
Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
console.log("[Biometric] Using fallback fingerprint:", fallbackFingerprint);
return fallbackFingerprint;
}
}
/**
* Register biometric for a user - links their username to this device's biometric
*/
export async function registerBiometric(username) {
try {
console.log("[Biometric] Registering biometric for:", username);
// Check availability first
const availability = await isBiometricAvailable();
if (!availability.available) {
throw new Error(availability.reason);
}
// Get device info
const deviceInfo = getDeviceInfo();
const deviceFingerprint = generateDeviceFingerprint();
console.log(
"[Biometric] Device:",
deviceInfo.name,
"Fingerprint:",
deviceFingerprint
);
// Validate fingerprint was generated
if (!deviceFingerprint) {
throw new Error(
"Failed to generate device fingerprint. Please try again."
);
}
// Test biometric authentication
console.log("[Biometric] Testing device biometric...");
// Create a simple credential to verify biometric works
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyOptions = {
challenge: challenge,
rp: {
name: "HOP Worship",
id:
window.location.hostname === "localhost"
? "localhost"
: window.location.hostname,
},
user: {
id: new TextEncoder().encode(username),
name: username,
displayName: username,
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required", // This triggers fingerprint/face
requireResidentKey: false,
},
timeout: 60000,
attestation: "none",
};
console.log("[Biometric] Prompting for fingerprint/face...");
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential) {
throw new Error("Biometric verification failed");
}
console.log("[Biometric] Biometric verified! Saving to backend...");
// Register with backend - just store device fingerprint linked to username
const registerPayload = {
username: username,
deviceName: deviceInfo.name,
deviceFingerprint: deviceFingerprint,
deviceInfo: deviceInfo.info,
};
console.log("[Biometric] Sending registration to backend:", {
username,
deviceName: deviceInfo.name,
deviceFingerprint,
});
const response = await fetch(`${API_BASE}/api/biometric/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(registerPayload),
});
console.log("[Biometric] Backend response status:", response.status);
const result = await response.json();
console.log("[Biometric] Backend response:", result);
if (!response.ok || !result.success) {
throw new Error(result.error || result.message || "Registration failed");
}
// Store username locally so we can auto-fill on next visit
localStorage.setItem("biometric_username", username);
localStorage.setItem("biometric_device", deviceFingerprint);
console.log("[Biometric] Registration successful!");
return { success: true };
} catch (error) {
console.error("[Biometric] Registration error:", error);
let errorMessage = error.message;
if (error.name === "NotAllowedError") {
errorMessage =
"Biometric was cancelled. Please try again and allow access.";
} else if (error.name === "SecurityError") {
errorMessage = "Security error: This feature requires HTTPS.";
}
return { success: false, error: errorMessage };
}
}
/**
* Authenticate using biometric - unlocks stored credentials
*/
export async function authenticateBiometric() {
try {
console.log("[Biometric] Starting biometric authentication...");
// Check availability
const availability = await isBiometricAvailable();
if (!availability.available) {
throw new Error(availability.reason);
}
// Get stored username (optional - backend can lookup by device fingerprint)
const savedUsername = localStorage.getItem("biometric_username");
const deviceFingerprint = generateDeviceFingerprint();
console.log(
"[Biometric] Saved username:",
savedUsername || "none (will lookup by device)"
);
console.log("[Biometric] Device fingerprint:", deviceFingerprint);
// Prompt for biometric verification
console.log("[Biometric] Prompting for fingerprint/face authentication...");
// Use a simple WebAuthn prompt to trigger device biometric
// This creates a temporary credential just to verify biometric
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
try {
// Try to create a simple credential to trigger biometric prompt
const publicKeyOptions = {
challenge: challenge,
rp: {
name: "HOP Worship",
id:
window.location.hostname === "localhost"
? "localhost"
: window.location.hostname,
},
user: {
id: new Uint8Array(16), // Random ID
name: savedUsername || "user",
displayName: savedUsername || "User",
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required", // This triggers fingerprint/face
},
timeout: 60000,
attestation: "none",
};
await navigator.credentials.create({
publicKey: publicKeyOptions,
});
console.log("[Biometric] Biometric verified!");
} catch (bioError) {
// If user cancelled or biometric failed
if (bioError.name === "NotAllowedError") {
throw new Error("Biometric authentication was cancelled");
}
console.log(
"[Biometric] Biometric prompt failed, but continuing:",
bioError.message
);
// Continue anyway - backend will verify based on device fingerprint
}
console.log("[Biometric] Sending authentication request to backend...");
// Send to backend for verification (username optional - backend can lookup by device)
const authPayload = {
deviceFingerprint: deviceFingerprint,
};
if (savedUsername) {
authPayload.username = savedUsername;
}
console.log("[Biometric] Sending auth request:", authPayload);
const response = await fetch(`${API_BASE}/api/biometric/authenticate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(authPayload),
});
console.log("[Biometric] Backend response status:", response.status);
const result = await response.json();
console.log("[Biometric] Backend response:", result);
if (!result.success) {
throw new Error(result.error || "Authentication failed");
}
console.log("[Biometric] Login successful for user:", result.username);
return {
success: true,
username: result.username,
role: result.role,
permissions: result.permissions,
};
} catch (error) {
console.error("[Biometric] Authentication error:", error);
let errorMessage = error.message;
if (error.name === "NotAllowedError") {
errorMessage = "Biometric was cancelled. Please try again.";
} else if (error.name === "NotFoundError") {
errorMessage =
"No biometric setup found. Please use password login and setup biometric.";
}
return { success: false, error: errorMessage };
}
}
/**
* Remove biometric authentication for a user
*/
export async function removeBiometric(username) {
try {
console.log("[Biometric] Removing biometric for:", username);
const response = await fetch(
`${API_BASE}/api/biometric/remove/${username}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
console.log("[Biometric] Remove response:", result);
if (!response.ok) {
throw new Error(result.error || "Failed to remove biometric");
}
if (result.success) {
console.log(
"[Biometric] Successfully removed",
result.deleted_count,
"credential(s)"
);
return {
success: true,
message: `Removed ${result.deleted_count} biometric credential(s)`,
};
} else {
throw new Error(result.error || "Failed to remove biometric");
}
} catch (error) {
console.error("[Biometric] Error removing biometric:", error);
return { success: false, error: error.message };
}
}

View File

@@ -0,0 +1,109 @@
// Chord generation logic to insert into components
// Chord progression patterns for each key
const getChordProgression = (key) => {
const progressions = {
C: ["C", "Am", "F", "G", "Dm", "Em"],
"C#": ["C#", "A#m", "F#", "G#", "D#m", "Fm"],
D: ["D", "Bm", "G", "A", "Em", "F#m"],
"D#": ["D#", "Cm", "G#", "A#", "Fm", "Gm"],
E: ["E", "C#m", "A", "B", "F#m", "G#m"],
F: ["F", "Dm", "Bb", "C", "Gm", "Am"],
"F#": ["F#", "D#m", "B", "C#", "G#m", "A#m"],
G: ["G", "Em", "C", "D", "Am", "Bm"],
"G#": ["G#", "Fm", "C#", "D#", "A#m", "Cm"],
A: ["A", "F#m", "D", "E", "Bm", "C#m"],
"A#": ["A#", "Gm", "D#", "F", "Cm", "Dm"],
B: ["B", "G#m", "E", "F#", "C#m", "D#m"],
};
return progressions[key] || progressions["C"];
};
// Generate chords above lyrics
const handleKeyGenerate = async () => {
setIsGenerating(true);
try {
const chords = getChordProgression(currentKey);
const lyrics = song.lyrics || "";
const lines = lyrics.split("\n");
let lyricsWithChords = "";
let chordIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines
if (!line) {
lyricsWithChords += "\n";
continue;
}
// Skip metadata lines (verse, chorus, etc.)
if (
/^\[(verse|chorus|bridge|intro|outro|pre-chorus|prechorus|interlude|refrain)/i.test(
line
)
) {
lyricsWithChords += line + "\n";
continue;
}
// Add chord above the line (every 2-3 lines)
if (i % 2 === 0) {
const chord = chords[chordIndex % chords.length];
lyricsWithChords += chord + "\n";
chordIndex++;
}
lyricsWithChords += line + "\n";
}
// Update the song with generated chords
const updatedSong = {
...song,
key: currentKey,
lyrics: lyricsWithChords.trim(),
chords: chords.join(" - "),
};
// Save to database
const response = await fetch(`http://localhost:5100/api/songs/${song.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSong),
});
if (response.ok) {
setSong(updatedSong);
alert("Chords generated and saved successfully!");
} else {
alert("Failed to save chords");
}
} catch (error) {
console.error("Error generating chords:", error);
alert("Error generating chords");
} finally {
setIsGenerating(false);
}
};
const handleTranspose = (steps) => {
const notes = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const currentIndex = notes.indexOf(currentKey);
const newIndex = (currentIndex + steps + 12) % 12;
setCurrentKey(notes[newIndex]);
};

View File

@@ -0,0 +1,611 @@
/* stylelint-disable-next-line at-rule-no-unknown */
@tailwind base;
/* stylelint-disable-next-line at-rule-no-unknown */
@tailwind components;
/* stylelint-disable-next-line at-rule-no-unknown */
@tailwind utilities;
/* Modern, clean, elegant base styles */
* {
box-sizing: border-box;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* iPhone-specific fixes */
@supports (-webkit-touch-callout: none) {
/* iOS Safari specific styles */
input, textarea, select, button {
font-size: 16px !important; /* Prevent zoom on input focus */
}
h1 { font-size: clamp(1.5rem, 5vw, 2rem) !important; }
h2 { font-size: clamp(1.25rem, 4.5vw, 1.75rem) !important; }
h3 { font-size: clamp(1.125rem, 4vw, 1.5rem) !important; }
h4 { font-size: clamp(1rem, 3.5vw, 1.25rem) !important; }
.text-xs { font-size: clamp(0.625rem, 2.5vw, 0.75rem) !important; }
.text-sm { font-size: clamp(0.75rem, 3vw, 0.875rem) !important; }
.text-base { font-size: clamp(0.875rem, 3.5vw, 1rem) !important; }
.text-lg { font-size: clamp(1rem, 4vw, 1.125rem) !important; }
.text-xl { font-size: clamp(1.125rem, 4.5vw, 1.25rem) !important; }
.text-2xl { font-size: clamp(1.25rem, 5vw, 1.5rem) !important; }
.text-3xl { font-size: clamp(1.5rem, 5.5vw, 1.875rem) !important; }
.text-4xl { font-size: clamp(1.75rem, 6vw, 2.25rem) !important; }
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
background: #f8f9fa;
color: #212529;
line-height: 1.6;
overflow-x: hidden;
max-width: 100vw;
}
html {
overflow-x: hidden;
max-width: 100vw;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Modal overlay improvements */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
overflow: auto;
padding: 1rem;
}
@media (max-width: 640px) {
.modal-overlay {
padding: 0.5rem;
}
}
.modal-content {
background: white;
border-radius: 16px;
width: 100%;
max-height: 90vh;
overflow: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
}
@media (max-width: 640px) {
.modal-content {
border-radius: 12px;
max-height: 95vh;
}
}
/* Typography - Modern and clean */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
margin: 0;
}
h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); }
h2 { font-size: clamp(1.5rem, 3.5vw, 2rem); }
h3 { font-size: clamp(1.25rem, 3vw, 1.5rem); }
/* Section 4: Typography weight increase - thicker fonts for lyrics/chords */
pre,
.lyrics-display,
.chords-display {
font-weight: 600 !important;
}
.font-mono {
font-weight: 600 !important;
}
/* Responsive Container Utilities */
.container-responsive {
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 1rem;
}
@media (min-width: 640px) {
.container-responsive {
padding: 1.5rem;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
/* Tablet-specific optimizations */
.container-responsive {
padding: 1.75rem;
max-width: 900px;
}
.modal-content {
max-width: 90%;
margin: 0 auto;
}
/* Two-column layout for tablets */
.tablet-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Optimized button groups for tablets */
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-group .btn {
min-width: auto;
flex: 1 1 calc(50% - 0.25rem);
}
}
@media (min-width: 1024px) {
.container-responsive {
padding: 2rem;
}
}
/* Accessibility Improvements */
*:focus-visible {
outline: 3px solid #667eea;
outline-offset: 2px;
border-radius: 2px;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid #667eea;
outline-offset: 2px;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
/* High contrast mode support */
@media (prefers-contrast: high) {
*:focus-visible {
outline-width: 4px;
outline-color: currentColor;
}
}
/* Skip to main content link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #667eea;
color: white;
padding: 8px 16px;
text-decoration: none;
border-radius: 0 0 4px 0;
z-index: 10000;
font-weight: 600;
}
.skip-link:focus {
top: 0;
}
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Visually hidden but accessible to screen readers */
.visually-hidden-focusable:not(:focus):not(:focus-within) {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Reduced motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Modern Input Styles */
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="date"],
input[type="url"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e9ecef;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s ease;
background: white;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Modern Button Styles */
.btn {
padding: 0.75rem 1.5rem;
min-height: 44px; /* Touch target size - WCAG AAA compliance */
min-width: 44px; /* Touch target size - WCAG AAA compliance */
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
position: relative;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive button sizes for mobile */
@media (max-width: 640px) {
.btn {
padding: 0.65rem 1.25rem;
font-size: 0.95rem;
min-height: 48px; /* Larger for mobile touch */
}
}
/* Tablet button optimizations */
@media (min-width: 768px) and (max-width: 1023px) {
.btn {
padding: 0.7rem 1.4rem;
font-size: 0.975rem;
}
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
/* Modern Card Styles */
.card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
/* Responsive Grid System */
.grid-responsive {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.grid-responsive {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.grid-responsive {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1280px) {
.grid-responsive {
grid-template-columns: repeat(4, 1fr);
}
}
/* Mobile-First Responsive Navigation */
.mobile-menu {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 768px) {
.mobile-menu {
flex-direction: row;
gap: 1.5rem;
}
}
/* Touch-Friendly Spacing */
@media (max-width: 767px) {
.btn {
padding: 1rem 1.5rem;
font-size: 1.1rem;
}
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="date"],
textarea,
select {
padding: 1rem;
font-size: 1.1rem;
}
}
/* Modal Responsive Styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
overflow-y: auto;
}
.modal-content {
background: white;
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
@media (max-width: 640px) {
.modal-content {
padding: 1.5rem;
max-width: 95vw;
}
}
/* Smooth Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}
/* Custom Scrollbar for Modal - Enhanced styling with rounded edges */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #a78bfa #f3f4f6;
}
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
margin: 20px 0; /* Creates padding from top/bottom edges */
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #a78bfa 0%, #8b5cf6 100%);
border-radius: 10px;
border: 2px solid rgba(248, 249, 250, 0.8);
background-clip: padding-box;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%);
border-radius: 10px;
border: 2px solid rgba(248, 249, 250, 0.8);
background-clip: padding-box;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
/* Responsive Display Utilities */
.hidden {
display: none;
}
@media (min-width: 768px) {
.md\:flex {
display: flex !important;
}
.md\:hidden {
display: none !important;
}
.md\:block {
display: block !important;
}
}
@media (max-width: 767px) {
.md\:flex {
display: none !important;
}
}
/* Enhanced tablet breakpoints */
@media (min-width: 640px) and (max-width: 1023px) {
.tablet\:grid-cols-3 {
grid-template-columns: repeat(3, 1fr) !important;
}
.tablet\:text-lg {
font-size: 1.125rem !important;
}
}
/* Mobile-specific enhancements for Song Database */
@media (max-width: 768px) {
/* Optimize tap targets */
button, a, [role="button"], .cursor-pointer {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Prevent text selection on double-tap */
.bg-white.border {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Ensure profile song cards display properly - 2 columns on mobile */
.profile-songs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
width: 100%;
overflow: visible;
}
/* Smooth scrolling */
* {
-webkit-overflow-scrolling: touch;
}
/* Mobile modal optimization */
.fixed.inset-0 {
overscroll-behavior: contain;
}
/* Larger touch targets for mobile forms */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="date"],
textarea,
select {
min-height: 44px;
font-size: 16px; /* Prevents iOS zoom on focus */
}
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "./index.css";
import App from "./App";
// NUCLEAR OPTION: Completely kill ALL ResizeObserver errors
const errorHandler = (e) => {
const msg = (e && e.message) || (e && e.reason && e.reason.message) || "";
if (msg.includes("ResizeObserver") || msg.includes("loop")) {
e.stopImmediatePropagation && e.stopImmediatePropagation();
e.stopPropagation && e.stopPropagation();
e.preventDefault && e.preventDefault();
return false;
}
};
const originalError = console.error;
console.error = (...args) => {
const firstArg = args[0];
if (
firstArg &&
typeof firstArg === "string" &&
(firstArg.includes("ResizeObserver") || firstArg.includes("loop"))
) {
return;
}
originalError.apply(console, args);
};
window.addEventListener("error", errorHandler, { capture: true });
window.addEventListener("unhandledrejection", errorHandler, { capture: true });
// Register Service Worker for caching
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js")
.then((registration) => {
console.log("[App] Service Worker registered:", registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
})
.catch((error) => {
console.log("[App] Service Worker registration failed:", error);
});
});
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,396 @@
// LocalStorage fallback for offline mode
const STORAGE_KEYS = {
PROFILES: "hop_profiles",
SONGS: "hop_songs",
PLANS: "hop_plans",
PLAN_SONGS: "hop_plan_songs",
PROFILE_SONGS: "hop_profile_songs",
PROFILE_SONG_KEYS: "hop_profile_song_keys",
CURRENT_PROFILE: "hop_current_profile",
};
// Initialize with default data if empty
function initStorage() {
if (!localStorage.getItem(STORAGE_KEYS.PROFILES)) {
localStorage.setItem(
STORAGE_KEYS.PROFILES,
JSON.stringify([
{ id: 1, name: "Default Profile", default_key: "C", notes: "" },
])
);
}
if (!localStorage.getItem(STORAGE_KEYS.SONGS)) {
localStorage.setItem(STORAGE_KEYS.SONGS, JSON.stringify([]));
}
if (!localStorage.getItem(STORAGE_KEYS.PLANS)) {
localStorage.setItem(STORAGE_KEYS.PLANS, JSON.stringify([]));
}
if (!localStorage.getItem(STORAGE_KEYS.PLAN_SONGS)) {
localStorage.setItem(STORAGE_KEYS.PLAN_SONGS, JSON.stringify([]));
}
if (!localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS)) {
localStorage.setItem(STORAGE_KEYS.PROFILE_SONGS, JSON.stringify([]));
}
if (!localStorage.getItem(STORAGE_KEYS.PROFILE_SONG_KEYS)) {
localStorage.setItem(STORAGE_KEYS.PROFILE_SONG_KEYS, JSON.stringify([]));
}
}
initStorage();
// Helper functions
function getNextId(items) {
return items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1;
}
export const localStorageAPI = {
// Profiles
async getProfiles() {
const data = localStorage.getItem(STORAGE_KEYS.PROFILES);
return JSON.parse(data || "[]");
},
async createProfile(profile) {
const profiles = await this.getProfiles();
// Use provided ID if available (from backend), otherwise generate new one
const newProfile = {
...profile,
id: profile.id || getNextId(profiles),
};
profiles.push(newProfile);
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(profiles));
return newProfile;
},
async updateProfile(id, updates) {
const profiles = await this.getProfiles();
const index = profiles.findIndex((p) => p.id === id);
if (index >= 0) {
profiles[index] = { ...profiles[index], ...updates };
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(profiles));
} else {
// Profile doesn't exist, create it
const newProfile = { ...updates, id };
profiles.push(newProfile);
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(profiles));
return newProfile;
}
return profiles[index];
},
async deleteProfile(id) {
const profiles = await this.getProfiles();
const filtered = profiles.filter((p) => p.id !== id);
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(filtered));
// Also delete associated profile songs
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS);
const profileSongs = JSON.parse(data || "[]");
const filteredSongs = profileSongs.filter((ps) => ps.profile_id !== id);
localStorage.setItem(
STORAGE_KEYS.PROFILE_SONGS,
JSON.stringify(filteredSongs)
);
// Verify deletion
const afterProfiles = await this.getProfiles();
const stillExists = afterProfiles.some((p) => p.id === id);
if (stillExists) {
console.error(
"[localStorage.deleteProfile] WARNING: Profile still exists after deletion!"
);
}
return { success: true, id };
},
// Songs
async getSongs(query = "") {
const data = localStorage.getItem(STORAGE_KEYS.SONGS);
const songs = JSON.parse(data || "[]");
if (!query) return songs;
const q = query.toLowerCase();
return songs.filter(
(s) =>
(s.title || "").toLowerCase().includes(q) ||
(s.artist || "").toLowerCase().includes(q) ||
(s.band || "").toLowerCase().includes(q) ||
(s.singer || "").toLowerCase().includes(q)
);
},
async getSong(id) {
const songs = await this.getSongs();
return songs.find((s) => s.id === id);
},
async createSong(song) {
const songs = await this.getSongs();
const newSong = { ...song, id: getNextId(songs) };
songs.push(newSong);
localStorage.setItem(STORAGE_KEYS.SONGS, JSON.stringify(songs));
return newSong;
},
async updateSong(id, updates) {
const songs = await this.getSongs();
const index = songs.findIndex((s) => s.id === id);
if (index >= 0) {
songs[index] = { ...songs[index], ...updates };
localStorage.setItem(STORAGE_KEYS.SONGS, JSON.stringify(songs));
}
return songs[index];
},
async deleteSong(id) {
const songs = await this.getSongs();
const filtered = songs.filter((s) => s.id !== id);
localStorage.setItem(STORAGE_KEYS.SONGS, JSON.stringify(filtered));
return { success: true, id };
},
// Plans
async getPlans() {
const data = localStorage.getItem(STORAGE_KEYS.PLANS);
return JSON.parse(data || "[]");
},
async createPlan(plan) {
const plans = await this.getPlans();
const { songs, ...planData } = plan;
const newPlan = {
...planData,
id: getNextId(plans),
date: plan.date || new Date().toISOString().split("T")[0],
notes: plan.notes || "",
};
plans.push(newPlan);
localStorage.setItem(STORAGE_KEYS.PLANS, JSON.stringify(plans));
// Add songs to plan if provided
if (songs && songs.length > 0) {
const data = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
const allPlanSongs = JSON.parse(data || "[]");
// Get the max ID for proper ID generation
const maxId =
allPlanSongs.length > 0
? Math.max(...allPlanSongs.map((ps) => ps.id))
: 0;
const newPlanSongs = songs.map((song, idx) => ({
id: maxId + idx + 1,
plan_id: newPlan.id,
song_id: song.id,
order_index: idx,
}));
const updatedPlanSongs = [...allPlanSongs, ...newPlanSongs];
localStorage.setItem(
STORAGE_KEYS.PLAN_SONGS,
JSON.stringify(updatedPlanSongs)
);
}
return newPlan;
},
async updatePlan(id, updates) {
const plans = await this.getPlans();
const index = plans.findIndex((p) => p.id === id);
if (index >= 0) {
// Extract songs from updates to handle separately
const { songs, ...planUpdates } = updates;
// Update plan metadata
plans[index] = { ...plans[index], ...planUpdates };
localStorage.setItem(STORAGE_KEYS.PLANS, JSON.stringify(plans));
// If songs are provided, update plan songs
if (songs) {
// Delete existing plan songs
const data = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
const allPlanSongs = JSON.parse(data || "[]");
const filteredSongs = allPlanSongs.filter((ps) => ps.plan_id !== id);
// Get the max ID from all plan songs for proper ID generation
const maxId =
allPlanSongs.length > 0
? Math.max(...allPlanSongs.map((ps) => ps.id))
: 0;
// Add new plan songs with incremental IDs
const newPlanSongs = songs.map((song, idx) => ({
id: maxId + idx + 1,
plan_id: id,
song_id: song.id,
order_index: idx,
}));
const updatedPlanSongs = [...filteredSongs, ...newPlanSongs];
localStorage.setItem(
STORAGE_KEYS.PLAN_SONGS,
JSON.stringify(updatedPlanSongs)
);
}
}
return plans[index];
},
async deletePlan(id) {
console.log("localStorage.deletePlan called for id:", id);
const plans = await this.getPlans();
console.log(
"Plans before delete:",
plans.length,
plans.map((p) => ({ id: p.id, date: p.date }))
);
const filtered = plans.filter((p) => p.id !== id);
console.log(
"Plans after filter:",
filtered.length,
filtered.map((p) => ({ id: p.id, date: p.date }))
);
localStorage.setItem(STORAGE_KEYS.PLANS, JSON.stringify(filtered));
// Also delete associated plan songs
const data = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
const planSongs = JSON.parse(data || "[]");
const filteredSongs = planSongs.filter((ps) => ps.plan_id !== id);
localStorage.setItem(
STORAGE_KEYS.PLAN_SONGS,
JSON.stringify(filteredSongs)
);
// Verify deletion
const verifyPlans = await this.getPlans();
const stillExists = verifyPlans.find((p) => p.id === id);
if (stillExists) {
console.error("WARNING: Plan still exists after deletion!", stillExists);
}
return { success: true, id };
},
// Plan Songs
async getPlanSongs(planId) {
const data = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
const allPlanSongs = JSON.parse(data || "[]");
return allPlanSongs
.filter((ps) => ps.plan_id === planId)
.sort((a, b) => a.order_index - b.order_index);
},
async addSongToPlan(planId, songData) {
const data = localStorage.getItem(STORAGE_KEYS.PLAN_SONGS);
const planSongs = JSON.parse(data || "[]");
const newEntry = {
id: getNextId(planSongs),
plan_id: planId,
song_id: songData.song_id,
order_index: songData.order_index || 0,
};
planSongs.push(newEntry);
localStorage.setItem(STORAGE_KEYS.PLAN_SONGS, JSON.stringify(planSongs));
return newEntry;
},
// Current Profile
getCurrentProfile() {
return localStorage.getItem(STORAGE_KEYS.CURRENT_PROFILE);
},
setCurrentProfile(profileId) {
localStorage.setItem(STORAGE_KEYS.CURRENT_PROFILE, profileId.toString());
},
// Profile Songs (saved songs for each user profile)
async getProfileSongs(profileId) {
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS);
const allProfileSongs = JSON.parse(data || "[]");
const profileSongIds = allProfileSongs
.filter((ps) => ps.profile_id === profileId)
.map((ps) => ps.song_id);
// Get full song details
const songs = await this.getSongs();
return songs.filter((s) => profileSongIds.includes(s.id));
},
async addSongToProfile(profileId, songId) {
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS);
const profileSongs = JSON.parse(data || "[]");
// Check if already exists
const exists = profileSongs.find(
(ps) => ps.profile_id === profileId && ps.song_id === songId
);
if (exists) {
return exists;
}
const newEntry = {
id: getNextId(profileSongs),
profile_id: profileId,
song_id: songId,
added_at: new Date().toISOString(),
};
profileSongs.push(newEntry);
localStorage.setItem(
STORAGE_KEYS.PROFILE_SONGS,
JSON.stringify(profileSongs)
);
return newEntry;
},
async removeSongFromProfile(profileId, songId) {
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS);
const profileSongs = JSON.parse(data || "[]");
const filtered = profileSongs.filter(
(ps) => !(ps.profile_id === profileId && ps.song_id === songId)
);
localStorage.setItem(STORAGE_KEYS.PROFILE_SONGS, JSON.stringify(filtered));
return { success: true };
},
// Profile-specific song keys (for transpose)
async getProfileSongKey(profileId, songId) {
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONG_KEYS);
const keys = JSON.parse(data || "[]");
const entry = keys.find(
(k) => k.profile_id === profileId && k.song_id === songId
);
return entry ? entry.key : null;
},
async saveProfileSongKey(profileId, songId, key) {
const data = localStorage.getItem(STORAGE_KEYS.PROFILE_SONG_KEYS);
const keys = JSON.parse(data || "[]");
const existingIndex = keys.findIndex(
(k) => k.profile_id === profileId && k.song_id === songId
);
if (existingIndex >= 0) {
keys[existingIndex].key = key;
} else {
keys.push({
id: getNextId(keys),
profile_id: profileId,
song_id: songId,
key: key,
});
}
localStorage.setItem(STORAGE_KEYS.PROFILE_SONG_KEYS, JSON.stringify(keys));
return { success: true };
},
};
// Export raw storage keys so migration/utilities can reference correct names
export { STORAGE_KEYS };

View File

@@ -0,0 +1,317 @@
import { localStorageAPI, STORAGE_KEYS } from "./localStorage";
// Migration utility to copy localStorage data to backend
export async function migrateLocalStorageToBackend(customBase) {
// Determine API base dynamically from saved settings unless overridden
const apiSettings = (() => {
try {
return JSON.parse(localStorage.getItem("api_settings") || "{}");
} catch {
return {};
}
})();
const API_BASE = (() => {
if (customBase) return customBase;
if (apiSettings.protocol && apiSettings.hostname) {
const p = apiSettings.port || "";
const portPart = p ? `:${p}` : "";
return `${apiSettings.protocol}://${apiSettings.hostname}${portPart}/api`;
}
return `https://houseofprayer.ddns.net:8080/api`;
})();
console.log("[Migration] Upload API Base:", API_BASE);
try {
// Preload existing backend data to minimize duplicates
let existingSongs = [];
let existingProfiles = [];
let existingPlans = [];
try {
console.log("[Migration] Fetching existing backend data...");
const [songsRes, profilesRes, plansRes] = await Promise.all([
fetch(`${API_BASE}/songs`).then((r) => (r.ok ? r.json() : [])),
fetch(`${API_BASE}/profiles`).then((r) => (r.ok ? r.json() : [])),
fetch(`${API_BASE}/plans`).then((r) => (r.ok ? r.json() : [])),
]);
existingSongs = songsRes;
existingProfiles = profilesRes;
existingPlans = plansRes;
} catch (e) {}
const existingSongTitles = new Set(
existingSongs.map((s) => (s.title || "").toLowerCase())
);
const existingProfileIds = new Set(existingProfiles.map((p) => p.id));
const existingPlanDates = new Set(existingPlans.map((pl) => pl.date));
// 1. Migrate Songs
const songs = await localStorageAPI.getSongs("");
for (const song of songs) {
if (existingSongTitles.has((song.title || "").toLowerCase())) {
continue;
}
try {
const res = await fetch(`${API_BASE}/songs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(song),
});
if (res.ok) {
} else {
}
} catch (err) {}
}
// 2. Migrate Profiles
const profiles = await localStorageAPI.getProfiles();
for (const profile of profiles) {
if (existingProfileIds.has(profile.id)) {
continue;
}
try {
const res = await fetch(`${API_BASE}/profiles`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile),
});
if (res.ok) {
} else {
}
} catch (err) {}
}
// 3. Migrate Plans
const plans = await localStorageAPI.getPlans();
for (const plan of plans) {
if (existingPlanDates.has(plan.date)) {
continue;
}
try {
const planSongs = await localStorageAPI.getPlanSongs(plan.id);
const songsData = [];
for (const ps of planSongs) {
const song = await localStorageAPI.getSong(ps.song_id);
if (song) songsData.push({ ...song, order: ps.order_index });
}
const res = await fetch(`${API_BASE}/plans`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...plan, songs: songsData }),
});
if (res.ok) {
} else {
}
} catch (err) {}
}
// 4. Migrate Profile Songs
const allProfileSongs = JSON.parse(
localStorage.getItem(STORAGE_KEYS.PROFILE_SONGS) || "[]"
);
for (const ps of allProfileSongs) {
try {
const res = await fetch(`${API_BASE}/profiles/${ps.profile_id}/songs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ song_id: ps.song_id }),
});
if (res.ok) {
} else {
}
} catch (err) {
console.error(
`❌ Failed to migrate profile-song (profile ${ps.profile_id} song ${ps.song_id}):`,
err
);
}
}
// 5. Migrate Profile Song Keys (array form in localStorage)
const profileSongKeysArr = JSON.parse(
localStorage.getItem(STORAGE_KEYS.PROFILE_SONG_KEYS) || "[]"
);
for (const entry of profileSongKeysArr) {
if (!entry.profile_id || !entry.song_id || !entry.key) continue;
try {
const res = await fetch(
`${API_BASE}/profiles/${entry.profile_id}/songs/${entry.song_id}/key`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: entry.key }),
}
);
if (res.ok) {
console.log(
`✅ Migrated key for profile ${entry.profile_id} song ${entry.song_id}`
);
} else {
console.warn(
`⚠️ Failed to migrate key for profile ${entry.profile_id} song ${entry.song_id}`
);
}
} catch (err) {
console.error(
`❌ Failed to migrate key (profile ${entry.profile_id} song ${entry.song_id}):`,
err
);
}
}
return {
success: true,
migrated: {
songs: songs.length,
profiles: profiles.length,
plans: plans.length,
profileSongs: allProfileSongs.length,
keys: profileSongKeysArr.length,
},
};
} catch (error) {
return { success: false, error: error.message };
}
}
// Sync FROM backend TO localStorage (pull down all data)
export async function syncFromBackendToLocalStorage(customBase) {
const apiSettings = (() => {
try {
return JSON.parse(localStorage.getItem("api_settings") || "{}");
} catch {
return {};
}
})();
const API_BASE = (() => {
if (customBase) return customBase;
if (apiSettings.protocol && apiSettings.hostname) {
const p = apiSettings.port || "";
const portPart = p ? `:${p}` : "";
return `${apiSettings.protocol}://${apiSettings.hostname}${portPart}/api`;
}
return `https://houseofprayer.ddns.net:8080/api`;
})();
console.log("[Migration] Download API Base:", API_BASE);
try {
// 1. Sync Songs
const songsRes = await fetch(`${API_BASE}/songs`);
if (!songsRes.ok)
throw new Error(`Failed to fetch songs: ${songsRes.status}`);
const backendSongs = await songsRes.json();
const localSongs = await localStorageAPI.getSongs("");
const localSongIds = new Set(localSongs.map((s) => s.id));
let songsAdded = 0;
let songsUpdated = 0;
for (const song of backendSongs) {
// Fetch full song details
try {
const detailRes = await fetch(`${API_BASE}/songs/${song.id}`);
if (!detailRes.ok) {
continue;
}
const fullSong = await detailRes.json();
if (!fullSong.lyrics && !fullSong.chords) {
}
if (localSongIds.has(fullSong.id)) {
await localStorageAPI.updateSong(fullSong.id, fullSong);
songsUpdated++;
} else {
await localStorageAPI.createSong(fullSong);
songsAdded++;
}
} catch (err) {}
}
// 2. Sync Profiles
const profilesRes = await fetch(`${API_BASE}/profiles`);
if (!profilesRes.ok)
throw new Error(`Failed to fetch profiles: ${profilesRes.status}`);
const backendProfiles = await profilesRes.json();
const localProfiles = await localStorageAPI.getProfiles();
const localProfileIds = new Set(localProfiles.map((p) => p.id));
let profilesAdded = 0;
let profilesUpdated = 0;
for (const profile of backendProfiles) {
if (localProfileIds.has(profile.id)) {
await localStorageAPI.updateProfile(profile.id, profile);
profilesUpdated++;
} else {
await localStorageAPI.createProfile(profile);
profilesAdded++;
}
}
// 3. Sync Plans
const plansRes = await fetch(`${API_BASE}/plans`);
if (!plansRes.ok)
throw new Error(`Failed to fetch plans: ${plansRes.status}`);
const backendPlans = await plansRes.json();
const localPlans = await localStorageAPI.getPlans();
const localPlanIds = new Set(localPlans.map((p) => p.id));
let plansAdded = 0;
let plansUpdated = 0;
for (const plan of backendPlans) {
if (localPlanIds.has(plan.id)) {
await localStorageAPI.updatePlan(plan.id, plan);
plansUpdated++;
} else {
await localStorageAPI.createPlan(plan);
plansAdded++;
}
}
return {
success: true,
synced: {
songs: { added: songsAdded, updated: songsUpdated },
profiles: { added: profilesAdded, updated: profilesUpdated },
plans: { added: plansAdded, updated: plansUpdated },
},
};
} catch (error) {
return { success: false, error: error.message };
}
}
// Full bidirectional sync: merge localStorage and backend data
export async function fullSync(customBase) {
// First, push local changes to backend
const uploadResult = await migrateLocalStorageToBackend(customBase);
// Then, pull backend changes to local
const downloadResult = await syncFromBackendToLocalStorage(customBase);
// Trigger page refresh to show updated data without full reload
window.dispatchEvent(new CustomEvent("dataFullySynced"));
// Also trigger events that the app already listens for
window.dispatchEvent(new Event("songsChanged"));
window.dispatchEvent(new Event("plansChanged"));
return {
success: uploadResult.success && downloadResult.success,
upload: uploadResult,
download: downloadResult,
};
}
// Export functions to call from browser console
window.migrateData = migrateLocalStorageToBackend;
window.syncFromBackend = syncFromBackendToLocalStorage;
window.fullSync = fullSync;

View File

@@ -0,0 +1,12 @@
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function (app) {
app.use(
"/api",
createProxyMiddleware({
target: "http://localhost:8080",
changeOrigin: true,
logLevel: "debug",
})
);
};

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>
<script>
const MASTER_USERNAME = "hop";
const MASTER_PASSWORD_HASH = "5cdf907c69ae7a7f0c2e18a67e9b70a4c4fc35f9582637354c1bc45edf092a79";
function testLogin(e) {
e.preventDefault();
console.log("Form submitted via Enter or Click");
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const inputHash = CryptoJS.SHA256(password).toString();
console.log("Username:", username);
console.log("Hash matches:", inputHash === MASTER_PASSWORD_HASH);
if (username === MASTER_USERNAME && inputHash === MASTER_PASSWORD_HASH) {
document.getElementById('result').innerHTML = '<span style="color:green">✅ LOGIN SUCCESS! Enter key works!</span>';
} else {
document.getElementById('result').innerHTML = '<span style="color:red">❌ Invalid credentials</span>';
}
}
</script>
</head>
<body>
<h1>Direct Login Test (No React)</h1>
<form onsubmit="testLogin(event)">
<input type="text" id="username" placeholder="Username" required /><br/>
<input type="password" id="password" placeholder="Password" required /><br/>
<button type="submit">Login</button>
</form>
<div id="result"></div>
<p>Type hop / hop@2026ilovejesus and press Enter</p>
</body>
</html>

View File

@@ -0,0 +1,46 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
screens: {
xs: "475px",
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
// Custom tablet breakpoints for better iPad support
tablet: "768px", // Standard tablets (iPad Mini and up)
"tablet-lg": "1024px", // Large tablets (iPad Pro)
},
extend: {
colors: {
primary: {
50: "#f0f9ff",
100: "#e0f2fe",
200: "#bae6fd",
300: "#7dd3fc",
400: "#38bdf8",
500: "#0ea5e9",
600: "#0284c7",
700: "#0369a1",
800: "#075985",
900: "#0c4a6e",
},
purple: {
50: "#faf5ff",
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
},
},
},
plugins: [],
};