Initial commit - Church Music Database
This commit is contained in:
20
legacy-site/frontend/.env.ubuntu
Normal file
20
legacy-site/frontend/.env.ubuntu
Normal 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
|
||||
9
legacy-site/frontend/jsconfig.json
Normal file
9
legacy-site/frontend/jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"forceConsistentCasingInFileNames": false
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
17277
legacy-site/frontend/package-lock.json
generated
Normal file
17277
legacy-site/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
legacy-site/frontend/package.json
Normal file
39
legacy-site/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
legacy-site/frontend/postcss.config.js
Normal file
6
legacy-site/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
legacy-site/frontend/pre-start-check.sh
Executable file
8
legacy-site/frontend/pre-start-check.sh
Executable 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
|
||||
40
legacy-site/frontend/public/cache-buster.js
Normal file
40
legacy-site/frontend/public/cache-buster.js
Normal 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);
|
||||
}
|
||||
248
legacy-site/frontend/public/check-settings.html
Normal file
248
legacy-site/frontend/public/check-settings.html
Normal 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>
|
||||
BIN
legacy-site/frontend/public/church-logo.png
Normal file
BIN
legacy-site/frontend/public/church-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 432 KiB |
224
legacy-site/frontend/public/clear-cache.html
Normal file
224
legacy-site/frontend/public/clear-cache.html
Normal 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>
|
||||
31
legacy-site/frontend/public/clear-settings.html
Normal file
31
legacy-site/frontend/public/clear-settings.html
Normal 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>
|
||||
32
legacy-site/frontend/public/gradient-backdrop.svg
Normal file
32
legacy-site/frontend/public/gradient-backdrop.svg
Normal 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 |
150
legacy-site/frontend/public/index.html
Normal file
150
legacy-site/frontend/public/index.html
Normal 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>
|
||||
528
legacy-site/frontend/public/mobile-login-debug.html
Normal file
528
legacy-site/frontend/public/mobile-login-debug.html
Normal 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>
|
||||
199
legacy-site/frontend/public/service-worker.js
Normal file
199
legacy-site/frontend/public/service-worker.js
Normal 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 });
|
||||
});
|
||||
}
|
||||
});
|
||||
407
legacy-site/frontend/public/ui-test.html
Normal file
407
legacy-site/frontend/public/ui-test.html
Normal 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>
|
||||
924
legacy-site/frontend/src/AdminPage.js
Normal file
924
legacy-site/frontend/src/AdminPage.js
Normal 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;
|
||||
10492
legacy-site/frontend/src/App.js
Normal file
10492
legacy-site/frontend/src/App.js
Normal file
File diff suppressed because it is too large
Load Diff
69
legacy-site/frontend/src/LoginSimple.js
Normal file
69
legacy-site/frontend/src/LoginSimple.js
Normal 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;
|
||||
915
legacy-site/frontend/src/api.js
Normal file
915
legacy-site/frontend/src/api.js
Normal 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"));
|
||||
}
|
||||
}
|
||||
415
legacy-site/frontend/src/biometricAuth.js
Normal file
415
legacy-site/frontend/src/biometricAuth.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
109
legacy-site/frontend/src/chord-generation-snippet.js
Normal file
109
legacy-site/frontend/src/chord-generation-snippet.js
Normal 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]);
|
||||
};
|
||||
611
legacy-site/frontend/src/index.css
Normal file
611
legacy-site/frontend/src/index.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
59
legacy-site/frontend/src/index.js
Normal file
59
legacy-site/frontend/src/index.js
Normal 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>
|
||||
);
|
||||
396
legacy-site/frontend/src/localStorage.js
Normal file
396
legacy-site/frontend/src/localStorage.js
Normal 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 };
|
||||
317
legacy-site/frontend/src/migration.js
Normal file
317
legacy-site/frontend/src/migration.js
Normal 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;
|
||||
12
legacy-site/frontend/src/setupProxy.js
Normal file
12
legacy-site/frontend/src/setupProxy.js
Normal 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",
|
||||
})
|
||||
);
|
||||
};
|
||||
38
legacy-site/frontend/src/test-login.html
Normal file
38
legacy-site/frontend/src/test-login.html
Normal 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>
|
||||
46
legacy-site/frontend/tailwind.config.js
Normal file
46
legacy-site/frontend/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user